// /dashboard.html entry point: tab renderers + tab routing + refreshState. // Reads /api/state on cold load and after every async-form submit; live // updates run through `applyXxx` mutation handlers triggered by the // dashboard event stream (subscribed via `openStream` from common.js). See // docs/web-ui/shape.md::Shape (shared by both) for the broader contract. // // Pure helpers (DOM, side panel, path linkification) // live in `./common.js`; the flow-page surface (broker // terminal, @-mention composer) lives in `./flow.js` — `flow.html` and // `dashboard.html` each load their own bundle. // SW4RM (containers) domain lives in `./swarm.js`. import { marked } from "marked"; import { $, openStream, renderServerWarnings } from "./common.js"; import { el } from "@hive/shared/dom.js"; import { bindAsyncForms } from "@hive/shared/forms.js"; import { createTabStrip } from "@hive/shared/tabs.js"; import { containersState, syncContainersFromSnapshot } from "./state.js"; import { applyCapabilitiesChanged, applyToolGroupsChanged, fetchAndRenderCapabilities, fetchAndRenderToolGroups, initPermissions, } from "./permissions.js"; import { initJobqRollup, syncTransientsFromSnapshot, applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, applyTransientSet, applyTransientCleared, renderContainers, renderSelectionBar, } from "./swarm.js"; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file // previews work; flow.js does the same for the flow-page entry. window.marked = marked; (() => { // ─── async forms ──────────────────────────────────────────────────────── // Shared `data-async` submit interceptor (now in common.js so /core.html's // bundle gets it too). On success it re-runs refreshState unless the form // opts out via `data-no-refresh` (its mutation arrives via an SSE event). bindAsyncForms(() => refreshState()); // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the // operator is typing in one of them, skip the refresh — the next // tick (or a manual action) will pick it up after they blur. const MANAGED_SECTION_IDS = [ "containers-section", "inbox-section", "capabilities-section", "tool-groups-section", ]; //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails // re-applies after the render. (Long-content drill-ins — file // previews, diffs, logs, config — open in the side panel instead, // which lives outside the managed sections and survives re-render // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll("details[data-restore-key]")) { if (d.open) open.add(d.dataset.restoreKey); } } return open; } function restoreOpenDetails(open) { if (!open.size) return; for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll("details[data-restore-key]")) { if (open.has(d.dataset.restoreKey)) d.open = true; } } } function operatorIsTyping() { const el_ = document.activeElement; if (!el_ || el_ === document.body) return false; const tag = el_.tagName; if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") return false; return MANAGED_SECTION_IDS.some((id) => { const sect = document.getElementById(id); return sect && sect.contains(el_); }); } async function refreshState() { // Don't yank the form out from under the operator. Try again // shortly on the next tick; eventually they'll blur and the // refresh lands. if (operatorIsTyping()) { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(refreshState, 2000); return; } try { const resp = await fetch("/api/state"); if (!resp.ok) throw new Error("http " + resp.status); const s = await resp.json(); // Stash the latest snapshot for any sub-widget that wants a // synchronous read (e.g. the compose autocomplete pulls agent // names from here instead of refetching on every keystroke). window.__hyperhive_state = s; renderServerWarnings(s.server_warnings); // Hive identity: render the swarm/hive name as a headline at the // top of the SW4RM pane + update the page title once the // server-side display names are known. `hive_name` / `swarm_name` // come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars // (set by services.hyperhive.{hiveName,swarmName} nix options). // When unset we fall back gracefully — the headline stays hidden. const hiveId = $("swarm-identity"); if (hiveId) { const hive = s.hive_name; const swarm = s.swarm_name; if (hive || swarm) { const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm; hiveId.textContent = label; hiveId.hidden = false; document.title = label + " // h1ve-c0re"; } } const openDetails = snapshotOpenDetails(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); // Job-queue rollup feeds only the SW4RM queue-summary banner // (per-agent card badges are transient-only — see swarm.js). Its // own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not // read off `s` (this page's snapshot carries no jobq field). // initJobqRollup mounts once and is a no-op on later calls; the // mount's own effect handles the actual fetch. initJobqRollup(); renderContainers(s); restoreOpenDetails(openDetails); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // transients have their own events; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` // (tombstones, meta-input updates). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } catch (err) { console.error("refreshState failed", err); // Schedule a single retry on transient errors so the page // recovers from a brief network blip without making the // operator reload. pollTimer = setTimeout(refreshState, 5000); } } refreshState(); // ─── live updates: dashboard event stream ────────────────────────────── // The dashboard subscribes to /dashboard/stream for live mutation // events so the SW4RM / SYST3M panes update without an // operator action triggering a refreshState. // // 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). // // Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream` // and filter client-side — the dashboard ignores broker traffic // and FL0W ignores mutation events. const MUTATION_HANDLERS = { transient_set: applyTransientSet, transient_cleared: applyTransientCleared, container_state_changed: applyContainerStateChanged, container_removed: applyContainerRemoved, // rebuild_queue_changed: refreshes the SW4RM queue-summary banner // (see swarm.js) — a payload-less push trigger, same treatment // /builds.html gives it for its JobqGraph mount handle's .refresh() // (its own separate subscription). rebuild_queue_changed: applyRebuildQueueChanged, capabilities_changed: applyCapabilitiesChanged, tool_groups_changed: applyToolGroupsChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs on the // SAME kinds= filter share one upstream SSE connection — see // docs/web-ui/shape.md (SSE multiplexing paragraph) for the design // + Firefox throttling motivation. `openStream` returns an // EventSource-shaped facade with a graceful direct-EventSource // fallback when SharedWorker isn't supported. // // kinds= matches MUTATION_HANDLERS verbatim, so the stream carries // only the 7 wire kinds this page acts on. const es = openStream( "/api/dashboard/stream?kinds=" + "transient_set,transient_cleared," + "container_state_changed,container_removed,rebuild_queue_changed," + "capabilities_changed,tool_groups_changed", ); 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 the SSE disconnect // window. 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 ─────────────────────────────────────────────────────── // Hash-based: `#swarm` / `#system` / `#permissions` activate the // matching pane on the dashboard. Empty // hash defaults to SW4RM. FL0W and ST4TS are NOT tabs — // they're separate pages (`/flow.html`, `/stats.html`) reached from // the H0M3 hub. Tab routing only // applies when the tab DOM is present (e.g. not on the flow page // itself, where these elements don't exist and the loop no-ops). // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the // tab/pane toggle + aria-selected + routing; `activateTab` runs the // per-tab side-effects via the strip's onShow. Constructed below (after // the lazy-load fns it calls are defined). The responsive overflow menu // was dropped — if the strip ever runs out of room we move panes // to their own pages rather than hide them in a dropdown. function activateTab(target) { // Track active tab on the body so renderSelectionBar can gate // visibility (bar only belongs on SW4RM where agent cards live). document.body.dataset.activeTab = target; renderSelectionBar(Array.from(containersState.values())); // Permissions tables: SSE covers worker-applied changes // (capabilities_changed / tool_groups_changed); re-fetch on // activation as a safety net for any gap between SSE events and // the cold-load snapshot. if (target === "permissions") { initPermissions(); fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); } } // Wire the shared tab strip now that activateTab + the lazy-load fns it // calls are defined. The strip resolves the active tab from the hash // (default SW4RM), toggles the active tab/pane + aria-selected, and // fires activateTab for the per-tab side-effects on every change. createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab }); // Tab count pills — pure derived data from the existing state // stores so SSE-driven updates flow through without extra plumbing. // Set `hidden` when the count is zero so the pill doesn't draw // attention to an empty room. function setTabCount(tab, n) { const el_ = $("tab-count-" + tab); if (!el_) return; el_.textContent = String(n); el_.hidden = n <= 0; } /** Recompute every tab's count from the current state. Called on * every renderXxx that's tab-relevant. */ function refreshTabCounts() { // SW4RM — flag any container that's stale (needs_update). Empty // when everyone's current. Container-row pulse signals state // transitions; the pill catches "deploy-pending" specifically. let swarm = 0; for (const c of containersState.values()) { if (c.needs_update) swarm++; } setTabCount("swarm", swarm); } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE // event + refreshState call, so polling them is correct and cheap // — no per-renderer hookup needed. refreshTabCounts(); setInterval(refreshTabCounts, 1000); // Flow-specific code (broker terminal init + the @-mention composer) // lives in ./flow.js — loaded only by /flow.html. })();