diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index f18b406d..161bf773 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -1,13 +1,7 @@ -// Shared dashboard helpers — extracted from the original monolithic -// dashboard JS as step 1 of the #406 split. These bits are used by -// both the tab dashboard (index.html) and the flow page (flow.html): -// pure DOM helpers, the side-panel singleton, the OS-notification -// module, and the path-link / file-preview infrastructure for the -// side panel. -// -// Each page now has its own entry point — `./tabs.js` for index.html, -// `./flow.js` for flow.html — and both import from here directly -// (#406 steps 2 + 3 complete; #406 closed). +// Shared dashboard helpers used by both index.html (./tabs.js) and +// flow.html (./flow.js): pure DOM helpers, the side-panel singleton, +// the OS-notification module, and the path-link / file-preview +// infrastructure for the side panel. import { linkify as termLinkify } from '@hive/shared/terminal.js'; @@ -57,31 +51,23 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = // tied to its caller, so they don't generalise cleanly. We can lift // them when a second consumer needs the same shape. -// ─── shared-worker SSE pipe (#448) ────────────────────────────────────── -// Returns an EventSource-shaped object backed by a SharedWorker that -// holds ONE upstream `new EventSource(url)` and fans events out to -// every connected tab. Replaces direct `new EventSource(url)` at the -// dashboard's two consumer sites (tabs.js inline + flow.js via -// terminal.js's `streamFactory` option) so N hyperhive tabs share -// ONE backend connection — way under the browser's per-host -// connection cap, immune to per-tab throttling that drops the SSE -// when Firefox suspends background tabs. +// ─── shared-worker SSE pipe ───────────────────────────────────────────── +// Returns an EventSource-shaped facade backed by a SharedWorker that +// holds one upstream `new EventSource(url)` and fans events out to +// every connected tab. See docs/web-ui.md (SSE multiplexing paragraph) +// for the design + Firefox throttling motivation; graceful fallback to +// direct EventSource on environments without SharedWorker. // -// Graceful fallback to direct EventSource on environments without -// SharedWorker (some embedded browsers, some Safari versions). The -// per-tab connection cost is the same as today — no regression. -// -// The page consumer uses the returned object like a regular -// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()` -// tells the worker to drop the subscription; the worker closes the -// upstream EventSource when the last subscriber leaves. +// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()` +// drops the subscription (the worker closes the upstream when the last +// subscriber leaves). const SHARED_WORKER_PATH = '/static/stream-worker.js'; const SHARED_WORKER_NAME = 'hyperhive-stream'; // One SharedWorker port per page, reused by all openStream calls on // that page. Invalidated on `pagehide` so a bfcache restore picks up -// a fresh port (the cached one may have been collected if all other -// tabs closed while this page was frozen — argus nit on #453). +// a fresh port — the cached port may be dead if all other tabs +// closed while this page was frozen. let _sharedPort = null; function makeSharedPort() { if (typeof SharedWorker === 'undefined') return null; @@ -99,30 +85,11 @@ function getSharedPort() { return _sharedPort; } -// #515: detect when the SharedWorker has been killed. Firefox aggressively -// reclaims "idle" SharedWorkers under memory pressure (or just on tab -// lifecycle quirks we don't fully understand), and there's no native -// signal to the client when that happens — `postMessage` on a dead -// port silently no-ops, and the page just stops receiving events. The -// observable symptom is "dashboard never refreshes; F5 fixes it" (a -// fresh page creates a fresh worker), which is mara's report on #515. -// -// Pipeline: -// - The worker pings every connected port every WORKER_PING_INTERVAL_MS. -// - `route` (every message arrival, including pings) bumps -// `_lastWorkerActivityAt`. -// - `startWorkerWatchdog` polls every WORKER_WATCHDOG_INTERVAL_MS. -// If the page is visible AND we have active subs AND we haven't -// heard from the worker in > WORKER_DEAD_THRESHOLD_MS, we presume -// the worker is dead, log a warning, and re-subscribe on a fresh -// port. -// -// Numbers picked so a real Firefox tab-suspend / unsuspend cycle (which -// can pause the watchdog itself) doesn't false-positive: 90s without a -// 30s ping means at least three pings missed. Visibility-gated so a -// backgrounded tab — where Firefox throttles setInterval to 1Hz min and -// our healthcheck wouldn't trigger reliably anyway — doesn't try to -// reconnect uselessly. +// SharedWorker death detection: pings from the worker bump the +// activity clock; a visibility-gated watchdog polls and re-subscribes +// on a fresh port if the page has been silent past the threshold. +// See docs/web-ui.md (Worker-death self-heal paragraph) for the +// timing rationale + Firefox reclaim symptom. const WORKER_DEAD_THRESHOLD_MS = 90_000; const WORKER_WATCHDOG_INTERVAL_MS = 15_000; let _lastWorkerActivityAt = 0; @@ -164,25 +131,19 @@ function rebindOnFreshPort() { noteWorkerActivity(); } -// Registry of live subscriptions on this page. Keyed by url so a -// second openStream call for the same URL (would only happen on a -// hypothetical multi-consumer page) attaches to the existing route -// rather than overlapping. Each entry caches the route function so -// bfcache-restore re-bind can re-attach it to the fresh port. -// -// Today's pages only call openStream once with one URL; the registry -// shape just keeps the bfcache-restore path correct if that changes -// (e.g. /index.html later subscribing to two streams). +// Registry of live subscriptions on this page. Keyed by url; entries +// cache the route function so bfcache-restore re-bind can re-attach +// it to the fresh port. Today's pages only call openStream once with +// one URL; the registry shape just keeps the bfcache path correct +// if that changes. const _activeSubs = new Map(); -// One-shot wiring of the page-wide lifecycle hooks: on bfcache -// freeze (`pagehide { persisted: true }`) we unsubscribe so the -// worker can close the upstream when the last live subscriber -// leaves; on bfcache restore (`pageshow { persisted: true }`) we -// invalidate the cached port (it may be dead if all other tabs -// closed during the freeze) and re-attach every active subscription -// to a fresh port. argus nit on #453: without this, the consumer's -// onmessage stays bound but no events flow after a bfcache restore. +// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze +// we unsubscribe so the worker can close the upstream when the last +// live subscriber leaves; on bfcache restore we invalidate the cached +// port (may be dead after the freeze) and re-attach every active +// subscription to a fresh port. Without this, the consumer's +// onmessage stays bound but no events flow after restore. let _lifecycleBound = false; function bindLifecycleOnce() { if (_lifecycleBound) return; @@ -245,7 +206,7 @@ export function openStream(url) { }, }; const route = (e) => { - // #515: any message from the worker is proof of life — note it + // Any message from the worker is proof of life — note it // before the URL filter, since heartbeat pings carry no URL. noteWorkerActivity(); const m = e.data; @@ -272,8 +233,8 @@ export function openStream(url) { _activeSubs.set(url, { target, route }); port.addEventListener('message', route); port.postMessage({ kind: 'subscribe', url }); - // #515: seed the activity clock so the watchdog has a baseline; it - // would otherwise compare against 0 (epoch) and trigger immediately. + // Seed the activity clock so the watchdog has a baseline (would + // otherwise compare against 0 and trigger immediately). noteWorkerActivity(); return target; } @@ -330,13 +291,10 @@ export const Panel = (() => { root.classList.remove('open'); root.setAttribute('aria-hidden', 'true'); } - // #451: drag-to-resize the drawer's width. Listens on a thin - // hit-strip glued to the drawer's left edge; mousedown captures - // pointermove + pointerup on the document so the drag continues - // even if the cursor strays outside the 6px handle band. Width - // persists to localStorage so it survives page reload. The CSS - // clamps the value (min-width: 320px, max-width: 96vw) — drop - // unparseable / out-of-range stored values silently. + // Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel + // for the hit-strip + pointer-capture + localStorage persistence + // model; CSS clamps the stored value to min 320px / max 96vw and + // out-of-range stored values are dropped silently. const WIDTH_KEY = 'hyperhive:side-panel-width'; const WIDTH_MIN = 320; function clampWidth(w) { @@ -476,7 +434,7 @@ function mdNode(text) { window.marked.setOptions({ breaks: true, gfm: true }); div.innerHTML = window.marked.parse(text); // marked autolinks URLs but leaves them same-tab — open externally - // so a click never navigates away from the dashboard. (issue #233) + // so a click never navigates away from the dashboard. div.querySelectorAll('a[href]').forEach((a) => { a.target = '_blank'; a.rel = 'noopener noreferrer'; diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index 2b48915e..9ad25da7 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -8,12 +8,11 @@
-