From 1819bee43c2174fc2eb0f7848123c69e6a859f4d Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 02:25:24 +0200 Subject: [PATCH] dashboard: subscribe to /dashboard/stream for live updates (#406 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-step-2, the SSE subscription on /index.html was conditionally created by the broker-terminal IIFE that only ran on /flow.html (via the `if (!flow || !window.HiveTerminal) return;` guard) — so the dashboard only updated on cold load + after async-form submits. The split in step 2 made the gap more obvious; close it. Bare `EventSource` (the dashboard doesn't render broker rows, so none of the HiveTerminal infrastructure is wanted here). Each event's `kind` looks up against a `MUTATION_HANDLERS` table that fans out to the existing `applyXxx` handlers. Unknown kinds (broker `sent` / `delivered`, anything the backend adds later) silently no-op. On (re)connect we kick `refreshState()` to recover events lost during the disconnect window — same pattern as flow.js's `onStreamOpen`. EventSource handles auto-reconnect. #408 will give /index.html its own stream that omits broker traffic; for now both pages subscribe to `/dashboard/stream` and filter client-side. Closes the #406 architectural roadmap: - step 1 (#410): common.js extraction ✓ - step 2 (#412): split into app.js + flow.js entry points ✓ - step 3 (this PR): /index.html re-acquires its SSE subscription --- frontend/packages/dashboard/src/app.js | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 53b0cd90..24d22d83 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -1828,6 +1828,63 @@ window.marked = marked; NOTIF.bind(); Panel.bind(); + // ─── live updates: dashboard event stream (#406 step 3) ──────────────── + // The dashboard subscribes to /dashboard/stream for live mutation + // events so the SW4RM / Y3R C4LL / SYST3M panes update without an + // operator action triggering a refreshState. Pre-step-2 this wiring + // lived inside the broker-terminal IIFE which only fired on /flow.html + // — meaning /index.html only updated on cold load + after async-form + // submits. + // + // Bare `EventSource` (no terminal infrastructure needed — the + // dashboard doesn't render broker rows). Each event's `kind` is + // looked up against `MUTATION_HANDLERS`; unknown kinds (broker + // `sent` / `delivered`, anything new the backend adds) silently + // no-op. On (re)connect we kick a refreshState() to recover events + // lost during the disconnect window (same pattern as flow.js's + // onStreamOpen). + // + // `#408` will give /index.html its own stream that omits the + // broker traffic the dashboard never uses; for now both pages + // subscribe to `/dashboard/stream` and filter client-side. + const MUTATION_HANDLERS = { + approval_added: applyApprovalAdded, + approval_resolved: applyApprovalResolved, + question_added: applyQuestionAdded, + question_resolved: applyQuestionResolved, + transient_set: applyTransientSet, + transient_cleared: applyTransientCleared, + container_state_changed: applyContainerStateChanged, + container_removed: applyContainerRemoved, + tombstones_changed: applyTombstonesChanged, + meta_inputs_changed: applyMetaInputsChanged, + meta_update_running: applyMetaUpdateRunning, + rebuild_queue_changed: applyRebuildQueueChanged, + }; + (function bindDashboardStream() { + const es = new EventSource('/dashboard/stream'); + es.onmessage = (e) => { + let ev; + try { ev = JSON.parse(e.data); } catch { return; } + const h = MUTATION_HANDLERS[ev.kind]; + if (!h) return; // broker rows + future kinds — dashboard doesn't care + try { h(ev); } + catch (err) { console.error('dashboard SSE handler', ev.kind, err); } + }; + es.onopen = () => { + // Re-sync to recover events that fired during a disconnect + // window (issue #163). Initial connect also fires onopen — the + // first refreshState() above and this one race, but refreshState + // is idempotent so the second call just overwrites with the + // freshest snapshot. Cheap on a quiescent server, fine to repeat. + refreshState(); + }; + es.onerror = () => { + // EventSource auto-reconnects; nothing to do beyond logging. + console.debug('dashboard SSE error, will retry'); + }; + })(); + // ─── tab routing (#369) ──────────────────────────────────────────────── // Hash-based: `#swarm` / `#call` / `#system` activate the matching // pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT