The EdgeCommons Edge Console: an edge-deployed, real-time web UI to monitor and
command every edgecommons component on a site —
and the site's sole browser↔bus bridge (browsers speak HTTPS+WS to the console; only the
console speaks MQTT/UNS). It attaches to the site broker (the aggregation point every
device's uns-bridge relays into), consumes the
Unified Namespace (ecv1/{device}/{component}[/{instance}]/{class}[/channel], the instance token
optional), and needs zero per-component knowledge: the six consumer classes, each subscribed at
both the component (ecv1/+/+/{class}) and instance (ecv1/+/+/+/{class}) scope, cover the whole fleet.
Its focus is edge health (fleet liveness, per-value freshness, whole-device reachability)
and config review (every component's effective, redacted config from its cfg
announcements), alongside events, metrics, per-component logs, signals, and an RBAC-gated
command surface.
The console runs as one Rust binary, edge-console-gateway: a bus ingress over six UNS
wildcards, a unified in-memory fleet model with console-side miss-detection, an HTTP + WebSocket
gateway that fans a snapshot-then-deltas stream to browsers, and a Carbon/React UI. It serves an
edge-health Overview, config review, an events feed, a metrics table, per-component logs, and an
RBAC-gated command write path — all fed live over one WebSocket. The command write path is
RBAC-enforced; connections are assigned the configured default role through a pluggable resolver
at the WS upgrade, and the read path (fleet snapshot + live stream) is unauthenticated (see
"The WS gateway").
| Package | What it is |
|---|---|
gateway/ |
The console runtime — a standard edgecommons Rust component (com.mbreissi.edgecommons.EdgeConsole) that owns the bus ingress, the unified in-memory model, the command/descriptor gateway, /ws, /healthz, and static ui/dist serving. |
ui/ |
The IBM Carbon/React front end (Vite, g100 dark per the signed-off hi-fi): a WS client + client-side fleet store mirroring the gateway's fleet model, and the screens (Overview health, config review, events & alarms, metrics, logs, signals, topology, settings). |
protocol/ |
Shared TypeScript types: the browser WS API contract (snapshots, deltas, liveness) + UNS envelope shapes. A hard contract between the Rust gateway and ui/. |
test-configs/ |
A runnable sample config (the console's own knobs live under component.global.console). |
docs/ |
DESIGN.md v0.3, the UNS reconciliation + Phase-1 plan, and the lo-fi/hi-fi mockups. |
One connection — the site broker (messaging.local in the config; on Kubernetes, the
in-cluster broker) or, on a single edge device with no site broker, the device-local
Greengrass IPC bus (built with the gateway's greengrass feature and shipped as a Greengrass
component via the repo-root recipe.yaml — see docs/how-to-guides.md).
Through it, the ingress subscribes the six consumer-class wildcards, built via the library
(gg.uns().filter(cls, UnsScope.all()), never by hand):
ecv1/+/+/+/state ecv1/+/+/+/cfg ecv1/+/+/+/evt/#
ecv1/+/+/+/metric/# ecv1/+/+/+/data/# ecv1/+/+/+/log/#
Identity always comes from the envelope's top-level identity element. The bridge's Last
Will is a broker-published protobuf state envelope from uns-bridge with
status:"UNREACHABLE" on ecv1/{device}/uns-bridge/{instance}/state; the model treats
that envelope as whole-device UNREACHABLE containment. Raw payloads are not normal UNS data
and are dropped; tags._relay (the bridge hop tag) is cached but never used for business
logic.
The model (pure, injected clock) is the platform's retain substitute: a
timestamped last-known-value cache keyed by (device, component, instance, class[, channel])
— a late-joining browser gets every current value immediately and its age. On top of it,
console-side miss-detection (the platform's first — no component reports "I am late"):
- Cadence is derived from each component's
cfgannouncement (config.heartbeat.intervalSecs), defaulting to 5 s until it arrives. - The ladder: FRESH → WARN (>2×) → STALE (>2.5×) → OFFLINE (>5×), tunable, driven by a
1 s sweeper over the last
statekeepalive receipt. - Restart vs gap: an
uptimeSecsdecrease means restart. - Graceful
{"status":"STOPPED"}holds STOPPED (no staleness decay) until the next RUNNING state. - Whole-device UNREACHABLE (bridge LWT): the device subtree freezes, components report
UNREACHABLE by containment, terminal until the next
stateenvelope from that device.
The model exposes a snapshot API (FleetSnapshot, deterministic order) plus a delta
event stream (FleetDelta, monotonic seq) — the exact snapshot-then-deltas seam the
WS gateway fans out.
Late-join rehydration: on first sight of a device the console publishes the per-device
broadcast pair ecv1/{device}/_bcast/cmd/republish-state + …/republish-cfg
(fire-and-forget cmd notifications). The edgecommons library's RepublishListener answers
them in every component (all four languages), re-announcing state and cfg; the periodic
state keepalive independently converges liveness within one interval.
An HTTP + WebSocket server (Rust axum: serves /ws, a trivial /healthz
probe, and — opt-in — the console's own built UI as static files on that SAME origin)
fans the model's snapshot + delta stream out to browsers,
snapshot-then-deltas:
- On connect, a client's first frame must be
{"type":"hello","protocolVersion":7}(optionallyresumeSeq). The gateway replies with onesnapshot(the currentFleetSnapshot, carrying its last-foldedseq), then streams every subsequentdeltabatch (FleetDelta[], strictly increasingseq). - Resume: a reconnecting client sends
resumeSeq= the lastseqit applied. If a bounded recent-delta ring (default 1000) can prove contiguous coverage from there, the gateway sends only the misseddeltabatch — no snapshot. On any gap (evicted range, orresumeSeqahead of the server) it falls back to a freshsnapshot— correctness over cleverness. - Fanout + backpressure: every connected client is served independently; a client whose
transport stays backpressured (
bufferedAmountover a threshold) across several consecutive delta pushes is dropped-and-resnapshotted rather than queued — it never stalls delivery to any other client. - A periodic
heartbeatframe doubles as the tick that evicts a connected socket that never sendshello. - RBAC on the command write path. A config-driven
console.rbacpolicy (allow/deny per verb, per role) is enforced in the CommandGateway (gateway/src/command.rs), and a pluggable role resolver at the WS upgrade edge assigns each connection a role. The default resolver assigns the configured default role to every connection; production authentication (bearer/mTLS/OIDC → role) attaches at that resolver without changing the session loop. The read path (fleet snapshot + live stream) is unauthenticated — keep the bound port on a trusted network or terminate auth in front.
Set component.global.console.ws.webRoot to a filesystem path (the built ui/dist,
relative paths resolve against the process cwd) and the console becomes a genuinely
self-contained deployment: it serves its own UI as static files on the SAME
port/origin as the WS gateway — no separate nginx front or Vite process needed for a
built deployment.
- Opt-in, backward-compatible:
webRootunset (the default) is byte-for-byte the pre-existing behavior — only/healthz+ the/wsupgrade are handled; every other GET (including/) 404s. - Routing precedence:
/healthzfirst, then (only whenwebRootis set) static file serving for every otherGET; the/wsupgrade never competes with either — the Rust HTTP router handles the WebSocket upgrade before static fallback. - SPA fallback: a request whose path has no file extension (an app route, not an
asset like
/assets/app-<hash>.js) and doesn't resolve to a real file serves the rootindex.htmlinstead, so deep-linking into the UI's client-side router works. A missing path that DOES look like an asset (has an extension) still 404s for real. - Traversal guard: the request path is decoded into a whitelist of plain path
segments — any
..(or a decode failure / embedded NUL) is rejected with403before touching the filesystem; nothing outsidewebRootcan ever be served. - Caching:
index.htmlisno-cache(a redeploy must be picked up immediately); every other file gets a longimmutablelifetime (Vite content-hashes every non-index.htmlasset, so a changed file is always a new URL). - The read-only Settings screen (R6) reflects it — a "Serves UI: yes/no" row under
Connection, sourced from the same
console.ws.webRootknob. - Dev-mode Vite is unaffected:
npm run dev -w uistill proxies/wsto the gateway for hot-reload;webRootonly matters for a built deployment (npm run buildthen pointwebRootatui/dist). - TLS/HTTPS terminates in front. The gateway serves plain
http/WebSocket regardless ofwebRoot; serve browsers over HTTPS/WSS by terminating TLS at a reverse proxy, load balancer, or Ingress ahead of it.
The browser side of priority #1 — a Carbon (g100) React view fed 100 % live from the
C2 gateway (no mock data outside tests), faithful to docs/mockups-hifi.html:
- WS client (
ui/src/fleet/client.ts): dials the gateway, sends the version-stampedhello, and dispatchessnapshot/delta/heartbeat/errorframes. Reconnects with exponential backoff (1 s → 30 s), always offeringresumeSeqso the gateway resumes with only the missed deltas or re-snapshots. A detected seq gap forces an immediate resync (redial); a silent connection (no frame for 45 s = 3× the gateway heartbeat) is treated as dead; anunsupported-protocol-versionerror is fatal (stale tab — reload), never a retry loop. The socket is injected, so all of this is unit-tested with fakes. - Client fleet store (
ui/src/fleet/store.ts): the pure browser mirror of the gateway's model — applies the snapshot, folds deltas strictly inseqorder, computes the device-UNREACHABLE overlay exactly like the gateway, and heals the snapshot-under-outage corner (ladders hidden by the overlay) with the gateway's own recompute rule. Liveness itself is computed by the gateway; the browser only applies transitions. Clock skew is handled with a per-frameclientReceipt − serverAtoffset so ages stay honest. (Note:value-updateddeltas carry no body — cached value bodies refresh via snapshots only; edge-health needs none of them live.) - The view (
ui/src/health/): summary-before-detail — fleet-health donut + counts-by-status, needs-attention/devices/live-stream tiles, inline issue notes (OFFLINE = error, STALE = warning, one containment note per UNREACHABLE device), then the fleet table grouped by device (collapsible group rows with worst-of rollups; per-component status tag, live last-state age, uptime — extrapolated only while provably alive — keepalive cadence + source, restarts). Liveness → Carbon: FRESH/OFFLINE/STOPPED use stock green/red/grayTags; WARN/STALE are built from the$support-warningtoken (Carbon ships no yellow tag); UNREACHABLE is the mockup's dashed-outline gray. Empty-fleet, connecting, reconnecting (last-known data stays visible under a banner) and fatal states are all explicit.
WS URL resolution (ui/src/config.ts): VITE_CONSOLE_WS_URL env override, else
derived from the page origin — ws(s)://{host}/ws. In dev, vite.config.ts proxies
/ws to 127.0.0.1:8443 (the server's console.ws.port default), so the
origin-derived URL works in both dev and production shapes.
The liveness stream deliberately carries no message bodies (a value-updated
delta is a change notification). Bodies travel over dedicated, versioned message
families on the same single WS connection, all held in the one unified in-memory model
the ingress feeds (gateway/src/model.rs): the liveness/LKV plane alongside the config,
events, metrics, and logs families:
- Config review — request/response + interest:
get-config{key}is answered from the retained-cfgcache (latest-wins, body VERBATIM — redaction already ran at the publisher:"***"masks,$secretrefs are vault pointers) and registers per-connection interest, so every latercfgarrival for that key is pushed unprompted.refresh-config{device}fires the per-device_bcastrepublish-cfgbroadcast (fire-and-forget; absence is silent until the device-side edgecommons S1 listener lands). The view (ui/src/configreview/) is the hi-fi's 340 px picker + Structured/Raw-JSON detail, with redaction rendered as redaction — closes priority #2. - Events — subscribe/stream (events are notifications, not
state):
subscribe-events[{limit}]answers ONE newest-firsteventsbacklog from the rolling history (bounded fleet-wide ring, default 1000, plus independent per-component rings, default 100 — drop-oldest, so a noisy component can't evict the others' history), then streams every arrival as aneventframe untilunsubscribe-events/disconnect. Theevt/{severity}/{type}channel convention is split leniently (splitEventChannel— the class is open; unknown severities render neutrally). The Events view (ui/src/events/) follows the mockup's "Events & alerts" screen scoped to what exists: three header tiles (recent count + severity legend, events/min sparkline, noisiest source), component + severity filters, and the live-appending newest-first log with per-row expandable detail (channel, publisher timestamp, tags, pretty body). Alarms are a first-class surface: the gateway derives them from theevtseverity stream (raise/clear, re-raise counts, device-containment) and servessubscribe-alarms/ack-alarmwith a livealarmssnapshot; the UI shows the active list with acknowledge and per-device containment. - Metrics — snapshot + live samples:
subscribe-metricsanswers ONEmetricssnapshot (every known series: latest value + a bounded recent series per(component, metric, measure), default 60 points/series, 2000 series, drop-oldest/counted), then pushesmetricupdate batches. Bodies fold leniently: the library's EMF shape (top-level numeric measures;_awsskipped) and bare numbers ("value") alike. The Metrics view (ui/src/metrics/) is a scannable table — one row per measure with the formatted latest value and a hand-rolled inline-SVG sparkline (time-scaled, subtle area fill, emphasized endpoint dot, native hover summary; single hue = the g100support-infoblue, validated against the dark surface — no chart dependency). - Logs — component-scoped snapshot + live stream:
subscribe-logs{key,limit?,levels?}answers onelogssnapshot from the boundedlog/{level}history, then pushes each new record as alogframe untilunsubscribe-logs{key}/disconnect. Records are normalized from the coreedgecommons.log.v1body and retaintimestamp,level,logger,message, optionalfields/error/truncated/dropped, and malformed/dropped counters. The Components page subscribes for the selected component so the embedded Logs tab can filter/follow/clear without navigating away.
Interest of all three families is per-connection: the owning view re-requests /
re-subscribes when the connection comes (back) up (the effect keys on the status),
and the fresh backlog/snapshot self-heals the client store — no client-side
resubscribe machinery. Client folds mirror the gateway bounds
(ui/src/fleet/event-log-store.ts, ui/src/fleet/metric-series-store.ts,
ui/src/fleet/log-store.ts), and the
version handshake turns any protocol skew into a clean "reload the page".
The console is configured like any edgecommons component; its own knobs live in the permissive
component.global.console subtree (no canonical-schema change — the bridge precedent):
All fields are optional (lenient parsing; every field falls back to its default — bindAddress
defaults to loopback 127.0.0.1, shown here as 0.0.0.0 to accept remote connections). workerThreads and
mallocArenaMax are launch-time controls: the deployed process must start with matching
EDGECONSOLE_WORKER_THREADS and MALLOC_ARENA_MAX environment values for them to be effective.
eventBufferCapacity bounds the gateway's internal live-event broadcast ring. See
test-configs/config.json for a complete runnable document — its messaging.local points at
the site broker, and the same file doubles as the --transport MQTT payload.
# Local dev: link:rust creates the gitignored Rust crate/proto links the
# official gateway build compiles against, from the sibling core checkout.
npm run link:rust
npm install
npm run build # protocol -> ui -> Rust edge-console-gateway
cargo test -p edge-console-gateway
npm test # protocol + ui unit suites (fake socket + injected clock - no live IO)
npm run coverage # vitest v8 coverage over protocol + ui (ecosystem gate)
npm run lint # eslint (flat config) over the whole workspace
# Run the gateway against the dev rig's site broker (uns-bridge dual-EMQX compose, port 1884):
target/release/edge-console-gateway \
--platform HOST --transport MQTT ./test-configs/config.json \
-c FILE ./test-configs/config.json \
-t gw-01
# Open the UI against the running gateway (Vite dev server; proxies /ws to 8443):
npm run dev -w ui # http://localhost:5173
# NOTE: after a protocol/ change, restart vite with
# --force (its dep-cache prebundles the old package)
# For a guided bring-up see docs/tutorial.md; for a full multi-device rig use the
# bottling-company-test harness.Note: package-lock.json is deliberately untracked while the sibling link is the dev path
(it would record the local stub, which does not exist in CI); it becomes tracked when the
console pins a published @edgecommons/edgecommons release.