Explanation — How the Edge Console works, and why
This page is the mental model. For exact options see reference/; for tasks, the how-to guides.
The one architectural fact: the console is the sole browser↔bus bridge
Section titled “The one architectural fact: the console is the sole browser↔bus bridge”Browsers cannot — and must not — speak to the message bus. Components are not individually reachable (on Greengrass they sit behind device-local IPC; even on MQTT you do not want every browser tab holding bus connections and consuming the shared-connection quota). So the console is the single point where the two worlds meet:
flowchart LR
subgraph Browsers
B1[Browser tab]
B2[Browser tab]
end
subgraph Console["edge-console (one Rust process, one site)"]
WS[WS session loop<br/>HTTP + WebSocket]
FM[Model<br/>unified in-memory state]
BI[Ingress<br/>6 UNS wildcards]
CG[CommandGateway<br/>RBAC -> request/reply]
end
subgraph Bus["Site UNS broker"]
UB[uns-bridge relays]
C1[component]
C2[component]
end
B1 & B2 <-->|HTTP+WS| WS
WS --- FM
WS --- CG
BI -->|subscribe| Bus
Bus --> BI --> FM
CG -->|cmd request/reply| Bus
UB --- C1 & C2
Everything a browser sees or does goes through this one process. That is what makes the console’s security posture, its single connection, and its in-memory model the whole story.
One bus: the site broker
Section titled “One bus: the site broker”The console attaches to exactly one bus — the site aggregation point, the broker every device’s
uns-bridge relays its device-local traffic into. It never fans out to per-device connections. On a
single-device deployment that broker is the device’s own local bus; on Kubernetes it is the in-cluster
broker. The console needs no list of devices or components up front: it discovers the entire fleet by
subscription.
The Unified Namespace, and the whole-fleet wildcards
Section titled “The Unified Namespace, and the whole-fleet wildcards”Every edgecommons topic is ecv1/{device}/{component}[/{instance}]/{class}[/channel] — the instance token
is optional, so a topic is component-scoped when it is absent and instance-scoped when it is present. The
console consumes the six consumer classes, each at both scopes (component ecv1/+/+/{class} and
instance ecv1/+/+/+/{class}) — the entire subscription surface, built through the
library’s uns().filter() (never a hand-assembled string):
ecv1/+/+/+/state ecv1/+/+/+/cfg ecv1/+/+/+/evt/#ecv1/+/+/+/metric/# ecv1/+/+/+/data/# ecv1/+/+/+/log/#Identity always comes from the envelope’s top-level identity element, never the topic — the device
is the last hierarchy level, and grouping/routing never parse the body or the topic string. The
uns-bridge Last Will is broker-published, but its payload is still a protobuf state envelope from
the bridge identity with status:"UNREACHABLE" on ecv1/{device}/uns-bridge/{instance}/state. That
envelope marks the whole device UNREACHABLE. Raw messages are not normal UNS data and are dropped.
The retain substitute: a timestamped last-known-value cache
Section titled “The retain substitute: a timestamped last-known-value cache”The platform deliberately uses no broker retain. Instead the console’s Model is a pure, in-memory
last-known-value (LKV) cache, keyed by (device, component, instance, class[, channel]), where every
entry carries its receipt timestamp. This is the crux of the design: a late-joining browser gets every
current value immediately and its age — the two things retain would have given, without retain’s
cross-broker fragility. The model has an injected clock and does no IO, so the entire liveness engine is
unit-tested with no sleeps and no broker.
Console-side miss-detection
Section titled “Console-side miss-detection”No component reports “I am late.” Liveness is therefore computed by the console from the state
keepalive backbone. It lives in the console because a consumer is the only party that can notice silence.
-
Cadence is derived, not assumed. The expected keepalive interval comes from each component’s own
cfgannouncement (config.heartbeat.intervalSecs), defaulting to 5 s until thatcfgarrives. -
The ladder, recomputed by a 1 s sweeper over the age of the last
state:State Condition FRESH last statewithinwarnMultiplier× interval (default 2×)WARN overdue past 2× (the “warn shading” band) STALE overdue past staleMultiplier(2.5×)OFFLINE overdue past offlineMultiplier(5×) — miss-detection’s “missing”STOPPED the component reported a graceful {"status":"STOPPED"}— held, no staleness decay, until the next RUNNING stateUNREACHABLE whole-device containment from the bridge LWT (below) -
Restart vs gap. A decrease in the reported
uptimeSecsmeans the component restarted (the restart counter ticks) — distinct from a silence gap. -
STOPPED is an explicit truth, not staleness — so it doesn’t decay. It holds until a RUNNING state returns.
-
A paused instance is expected quiet. The ladder measures the component’s
statekeepalive, which keeps ticking while one of its connections is deliberately paused — so a pause never trips staleness. The console reads the instance’s ownPAUSEDstate out of that keepalive’sinstances[]and keeps it out of the component’s connected-instance count, so an intentional pause reads as intentional everywhere rather than as a connection fault. -
Whole-device UNREACHABLE. When the bridge dies, the broker publishes its LWT and the console freezes that device’s subtree: every component under it reports UNREACHABLE by containment (“the road is down, not the houses” — you get one containment note, not N offline alarms). It is terminal until the next
stateenvelope arrives from that device — a state that reached the site broker proves the uplink relays again.
Two planes, two seams
Section titled “Two planes, two seams”The console keeps a hard line between “who’s alive” and “what’s the value”:
- The liveness stream carries no bodies. A
value-updateddelta is a change notification, not the value. This keeps the fanout cheap and the model authoritative — value bodies live in the LKV cache and the side stores. - Bodies travel over dedicated, versioned message families on the same one WebSocket connection,
each backed by a pure side store the ingress tees into: config review (
cfg), events (evt), metrics (metric), signals (data), and the runtime-attributes projection. A screen requests/subscribes what it needs; the store answers a snapshot then streams arrivals.
flowchart TB
BI[Ingress<br/>normalize each delivery]
subgraph Model["Model — one unified in-memory state"]
LKV[Liveness + LKV plane<br/>who's alive]
FAM[Body / activity families<br/>cfg · evt · metric · data · attributes · alarms]
end
BI --> LKV
BI --> FAM
LKV & FAM -->|snapshot then deltas / streams| WS[WS session loop]
WS --> Browser
Snapshot-then-deltas, resume, and backpressure isolation
Section titled “Snapshot-then-deltas, resume, and backpressure isolation”The gateway is a pure fanout core (the real sockets are a thin IO edge). Its contract:
- On connect, a client’s first frame must be
hellocarrying the protocol version. The gateway replies with one snapshot (the current fleet, stamped with its last foldedseq), then streams every subsequent delta batch in strictly increasingseq. - Resume: a reconnecting client offers
resumeSeq(the lastseqit applied). If a bounded recent-delta ring can prove contiguous coverage from there, the gateway sends only the missed deltas — no snapshot. On any gap it falls back to a fresh snapshot: correctness over cleverness. - Backpressure is isolated per client. A client whose transport stays backpressured across several delta pushes is dropped-and-resnapshotted rather than queued — it can never stall delivery to any other client.
- Versioned wire. Every frame carries the protocol version; a stale browser tab against a redeployed gateway gets a clean “reload the page”, never a silent misparse. The version handshake is why the whole UI heals itself on reconnect — a fresh snapshot/backlog replaces whatever the client held.
Commanding: the write path
Section titled “Commanding: the write path”The console’s write surface is invoke-command. The flow is deliberately narrow:
- the browser asks the gateway to invoke a verb (with optional args) on a target component;
- the gateway RBAC-checks it — a denied verb returns
FORBIDDENand never touches the bus; - it builds the target’s own
cmdinbox topic viauns().topicFor()and issues onemessaging.request()on the site bus (header.name= the verb, body = the args); - the
uns-bridgerewritesreply_totransparently, so a site→device request/reply just works; - the reply maps to a single
command-result— success, the component’s own coded error passed through verbatim, or a console-synthesized code (TIMEOUT/REQUEST_FAILED/INVALID_TARGET/MALFORMED_REPLY).
Every per-verb deadline is clamped to the uns-bridge reply-map TTL (the paired-knob rule — a deadline
that outlived the reply path would leak). The three universal built-ins — ping, reload-config,
get-configuration — are offered on every component; the console does not discover a component’s custom
verbs.
The UI: dynamic and hierarchy-driven
Section titled “The UI: dynamic and hierarchy-driven”The IBM Carbon / React front end renders whatever the deployment declares — it never hardcodes a
tier. The fleet table and the Components tree are grouped dynamically from each component’s identity
hier, so a two-level site→device fleet and a five-level enterprise→site→area→line→device fleet both
render correctly with no code change. The topology graph derives its nodes from identity and its edges
from each component’s cfg.
A discipline runs through every screen: surface what is not derivable rather than fake it. The
topology’s component-to-component dataflow edges are not drawn when there is no flow metadata on the
wire; a component’s custom command surface, custom panels, and per-signal engineering units/limits depend
on descriptor data from the component. The Logs tab likewise depends on actual log/{level} records on
the bus. The UI marks unavailable data honestly rather than inventing values.
Hosting additional applications
Section titled “Hosting additional applications”The Carbon/React operator UI is the console’s own front end, but it is not the only thing the console can serve. The console can also host additional, independently-built applications — a Gemba or Andon board, a purpose-built line dashboard, a television wall display — each with its own look, its own lifecycle, and its own audience, all fed from the same fleet model.
This does not weaken the single-bridge fact; it depends on it. Every hosted app, browser or native, still reaches the fleet only through the console — none of them opens a bus connection. What the console adds is a way to run several such apps side by side without letting one become a way into another:
- Each app is a registered principal, not just a folder. An app is declared in
console.appswith an id, a static bundle, an explicit set of allowed browser origins, the roles allowed to open it, and the data families it may see. The console serves the bundle at/apps/{id}/…and exposes a dedicated application WebSocket at/apps/{id}/ws. - A path is a packaging namespace, not a security boundary. Because several apps can sit under one
gateway origin, the application WebSocket is gated on an exact-origin allowlist per app — stricter
than the operator
/ws, which permits same-origin and header-less clients. An app declared with the originhttps://line-1-tv.example.internalcan be opened only by a client presenting exactly thatOrigin; one app’s page cannot select another app’s socket. A native client (which is not a browser and so supplies its ownOrigin) is admitted the same way: it sends the registered origin string and is otherwise indistinguishable from a browser app to the gateway. - Observe, never command. A hosted app subscribes to the data families it was granted — any of
fleet,events,metrics,logs,signals,attributes,alarms— and receives them as a rate-limited projection: the gateway coalesces updates to at most 30 per second per connection (latest-value-wins for state families; order preserved for events and logs; any drop under load is signalled to the client, never hidden). Commands are deliberately out of reach: the application WebSocket has no write path, so a wall display or a shared kiosk can render the fleet without being able to act on it.
The same fleet model that drives the operator UI thus drives every hosted board, at a cadence a passive television can sustain, with each app’s reach bounded by config rather than by trust in the network.
A note on security
Section titled “A note on security”Two current boundaries:
- Transport is plain HTTP + WebSocket. There is no built-in TLS listener. Serve browsers over
HTTPS/WSS by terminating TLS in front (reverse proxy / load balancer / Ingress). The UI derives
wss://…/wsfrom anhttps://page origin automatically, so no UI change is needed once TLS terminates ahead of the console. - Connections are not authenticated. RBAC enforcement on the command path is real (a config-driven
allow/deny per verb, fail-closed), but the console does not resolve the identity of a connection: every
connection is assigned the configured
defaultRole. And the read surface (snapshot + streams) is unauthenticated. Keep the console on a trusted network.
These boundaries are surfaced in the product (the Settings screen, the server startup log) as well as here.
Deployment shape
Section titled “Deployment shape”Because the console is a standard edgecommons component it deploys the same three ways as everything else — HOST, Greengrass, Kubernetes — and the library owns config/messaging/logging/metrics/heartbeat/shutdown. Two console-specific constraints follow from its nature:
- One replica per site broker. It holds long-lived WebSockets and an in-memory model; it is not horizontally scalable.
- Browser reachability is the console’s own concern, not the bus’s — a bound port on HOST/Greengrass,
a Service + Ingress on Kubernetes (no packaged chart is included). The
webRootoption lets one process serve both the WebSocket and the built UI, so a self-contained deployment needs no separate front.