Skip to content

edgecommons/edge-console

Repository files navigation

edge-console

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").

Workspace layout

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.

How the console consumes the UNS

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 cfg announcement (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 state keepalive receipt.
  • Restart vs gap: an uptimeSecs decrease 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 state envelope 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.

The WS gateway

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} (optionally resumeSeq). The gateway replies with one snapshot (the current FleetSnapshot, carrying its last-folded seq), then streams every subsequent delta batch (FleetDelta[], strictly increasing seq).
  • Resume: a reconnecting client sends resumeSeq = the last seq it applied. If a bounded recent-delta ring (default 1000) can prove contiguous coverage from there, the gateway sends only the missed delta batch — no snapshot. On any gap (evicted range, or resumeSeq ahead of the server) it falls back to a fresh snapshot — correctness over cleverness.
  • Fanout + backpressure: every connected client is served independently; a client whose transport stays backpressured (bufferedAmount over a threshold) across several consecutive delta pushes is dropped-and-resnapshotted rather than queued — it never stalls delivery to any other client.
  • A periodic heartbeat frame doubles as the tick that evicts a connected socket that never sends hello.
  • RBAC on the command write path. A config-driven console.rbac policy (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.

Serving the console's own UI (console.ws.webRoot)

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: webRoot unset (the default) is byte-for-byte the pre-existing behavior — only /healthz + the /ws upgrade are handled; every other GET (including /) 404s.
  • Routing precedence: /healthz first, then (only when webRoot is set) static file serving for every other GET; the /ws upgrade 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 root index.html instead, 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 with 403 before touching the filesystem; nothing outside webRoot can ever be served.
  • Caching: index.html is no-cache (a redeploy must be picked up immediately); every other file gets a long immutable lifetime (Vite content-hashes every non-index.html asset, 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.webRoot knob.
  • Dev-mode Vite is unaffected: npm run dev -w ui still proxies /ws to the gateway for hot-reload; webRoot only matters for a built deployment (npm run build then point webRoot at ui/dist).
  • TLS/HTTPS terminates in front. The gateway serves plain http/WebSocket regardless of webRoot; serve browsers over HTTPS/WSS by terminating TLS at a reverse proxy, load balancer, or Ingress ahead of it.

The edge-health UI

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-stamped hello, and dispatches snapshot/delta/heartbeat/error frames. Reconnects with exponential backoff (1 s → 30 s), always offering resumeSeq so 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; an unsupported-protocol-version error 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 in seq order, 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-frame clientReceipt − serverAt offset so ages stay honest. (Note: value-updated deltas 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/gray Tags; WARN/STALE are built from the $support-warning token (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.

Config review, events, metrics & logs

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-cfg cache (latest-wins, body VERBATIM — redaction already ran at the publisher: "***" masks, $secret refs are vault pointers) and registers per-connection interest, so every later cfg arrival for that key is pushed unprompted. refresh-config{device} fires the per-device _bcast republish-cfg broadcast (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-first events backlog 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 an event frame until unsubscribe-events/disconnect. The evt/{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 the evt severity stream (raise/clear, re-raise counts, device-containment) and serves subscribe-alarms/ack-alarm with a live alarms snapshot; the UI shows the active list with acknowledge and per-device containment.
  • Metrics — snapshot + live samples: subscribe-metrics answers ONE metrics snapshot (every known series: latest value + a bounded recent series per (component, metric, measure), default 60 points/series, 2000 series, drop-oldest/counted), then pushes metric update batches. Bodies fold leniently: the library's EMF shape (top-level numeric measures; _aws skipped) 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 g100 support-info blue, validated against the dark surface — no chart dependency).
  • Logs — component-scoped snapshot + live stream: subscribe-logs{key,limit?,levels?} answers one logs snapshot from the bounded log/{level} history, then pushes each new record as a log frame until unsubscribe-logs{key}/disconnect. Records are normalized from the core edgecommons.log.v1 body and retain timestamp, level, logger, message, optional fields/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".

Configuration

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):

"component": {
  "global": {
    "console": {
      "ws":        { "port": 8443, "bindAddress": "0.0.0.0",
                     "heartbeatIntervalMs": 15000 },                  // WS gateway endpoint
      "staleness": { "warnMultiplier": 2, "staleMultiplier": 2.5,
                     "offlineMultiplier": 5, "defaultIntervalSecs": 5,
                     "sweepIntervalMs": 1000 },
      "cache":     { "maxChannelsPerComponent": 1024 },
      "events":    { "maxEvents": 1000, "maxPerComponent": 100 },   // rolling evt history
      "metrics":   { "maxSeriesPoints": 60, "maxSeries": 2000 },    // metric series bounds
      "logs":      { "maxRecords": 5000, "maxPerComponent": 1000,
                     "defaultTail": 500, "maxTail": 2000 },         // log history bounds
      "runtime":   { "workerThreads": 4, "mallocArenaMax": 2,
                     "eventBufferCapacity": 512 }                   // process + WS buffer knobs
    }
  }
}

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.

Build, test, run

# 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.

About

EdgeCommons edge-console: edge-deployed real-time web UI (IBM Carbon/React + Node) to monitor and command ggcommons components over the UNS bus — the sole browser-to-bus bridge (HTTPS/WS to MQTT/IPC).

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages