Skip to content

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.

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.

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 cfg announcement (config.heartbeat.intervalSecs), defaulting to 5 s until that cfg arrives.

  • The ladder, recomputed by a 1 s sweeper over the age of the last state:

    State Condition
    FRESH last state within warnMultiplier × 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 state
    UNREACHABLE whole-device containment from the bridge LWT (below)
  • Restart vs gap. A decrease in the reported uptimeSecs means 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 state keepalive, which keeps ticking while one of its connections is deliberately paused — so a pause never trips staleness. The console reads the instance’s own PAUSED state out of that keepalive’s instances[] 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 state envelope arrives from that device — a state that reached the site broker proves the uplink relays again.

The console keeps a hard line between “who’s alive” and “what’s the value”:

  • The liveness stream carries no bodies. A value-updated delta 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 hello carrying the protocol version. The gateway replies with one snapshot (the current fleet, stamped with its last folded seq), then streams every subsequent delta batch in strictly increasing seq.
  • Resume: a reconnecting client offers resumeSeq (the last seq it 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.

The console’s write surface is invoke-command. The flow is deliberately narrow:

  1. the browser asks the gateway to invoke a verb (with optional args) on a target component;
  2. the gateway RBAC-checks it — a denied verb returns FORBIDDEN and never touches the bus;
  3. it builds the target’s own cmd inbox topic via uns().topicFor() and issues one messaging.request() on the site bus (header.name = the verb, body = the args);
  4. the uns-bridge rewrites reply_to transparently, so a site→device request/reply just works;
  5. 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 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.

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.apps with 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 origin https://line-1-tv.example.internal can be opened only by a client presenting exactly that Origin; one app’s page cannot select another app’s socket. A native client (which is not a browser and so supplies its own Origin) 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.

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://…/ws from an https:// 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.

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 webRoot option lets one process serve both the WebSocket and the built UI, so a self-contained deployment needs no separate front.