// /dashboard.html entry point: tab renderers + tab routing + refreshState // + notification deltas. 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.md::Shape (shared by // both) for the broader contract. // // Pure helpers (DOM, side panel, OS notifications, path linkification) // live in `./common.js`; the flow-page surface (operator inbox, 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 { $, el, Panel, NOTIF, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot, } from './state.js'; import { fmtAgo, fmtDuration } from './util.js'; import { applyCapabilitiesChanged, applyToolGroupsChanged, fetchAndRenderCapabilities, fetchAndRenderToolGroups, initPermissions, } from './permissions.js'; import { applySchedulesChanged, applyRemindersChanged, refreshSchedules, refreshReminders, activeScheduleCount, } from './schedules.js'; import { initCall, refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount, syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved, renderApprovals, activeApprovalCount, syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, renderQuestions, activeQuestionCount, } from './call.js'; import { syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot, applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, applyTransientSet, applyTransientCleared, renderContainers, renderContainersFromState, renderSelectionBar, renderPeerHives, } 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; (() => { // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads. const seenApprovals = new Set(); const seenQuestions = new Set(); let seededNotify = false; function notifyDeltas(s) { const approvals = s.approvals || []; const questions = s.questions || []; if (!seededNotify) { // First render after page load — fill the "seen" sets without // firing notifications. We only want to notify on NEW items // that arrived while the page is open. The inbox no longer // needs seeding here: it's derived from the broker stream which // does its own per-event notification on live arrival, and // history-replayed events are silent by virtue of `fromHistory`. for (const a of approvals) seenApprovals.add(a.id); for (const q of questions) seenQuestions.add(q.id); seededNotify = true; return; } for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); const verb = a.kind === 'spawn' ? 'spawn approval' : a.kind === 'init_config' ? 'config-init approval' : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`, 'hyperhive:approval:' + a.id); } for (const q of questions) { if (seenQuestions.has(q.id)) continue; seenQuestions.add(q.id); const targetLabel = q.target || 'operator'; NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`, q.question.slice(0, 120), 'hyperhive:question:' + q.id); } } // ─── 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()); // Live ticker for approval request-age chips. Approval cards only // re-render on `approval_added`/`approval_resolved` SSE events, so // a request pending for an hour could still show "0s ago" without // this ticker. Also flips `.stale` (amber highlight) at exactly 1h // rather than only at the next re-render. // Live countdown for reminder due-at labels and schedule next-fire // cells. Both renderers stamp `data-due-at` on the element so this // single ticker keeps them fresh without triggering a full re-render. // `.reminder-due` -> "overdue X ago" / "in Y" // `.sched-due` -> same pattern setInterval(() => { const now = Math.floor(Date.now() / 1000); document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => { const requestedAt = Number(node.getAttribute('data-requested-at')); if (!Number.isFinite(requestedAt)) return; const ageSec = Math.max(0, now - requestedAt); node.textContent = 'requested ' + fmtAgo(requestedAt); node.classList.toggle('stale', ageSec >= 3600); }); document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => { const dueAt = Number(node.getAttribute('data-due-at')); if (!Number.isFinite(dueAt)) return; const dueIn = dueAt - now; node.textContent = dueIn <= 0 ? 'overdue ' + fmtAgo(dueAt) : (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn); }); }, 1000); // ─── 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', 'questions-section', 'inbox-section', 'approvals-section', 'reminders-section', 'schedules-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; // Peer hives render as a headline section under SW4RM (no longer // a tab); renderPeerHives shows/hides its own headline block. renderPeerHives(s.peer_hives || []); renderServerWarnings(s.server_warnings); // (The M4TR1X surface is reachable from the H0M3 hub now, not the // dashboard tab strip — home.js gates its tile on matrix_gui_enabled.) // 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; // Preserve any (N) call-count prefix already applied by // refreshTabCounts so the title doesn't flicker on reload. const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || ''; document.title = existingPrefix + 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); // Rebuild-queue state feeds the SW4RM agent-card badges // (inFlightOpsByAgent); its own panel now lives on /core.html. syncRebuildQueueFromSnapshot(s); renderContainers(s); // Sync the derived approvals + questions stores from the // snapshot, then render. Live `*_added` / `*_resolved` events // mutate the stores directly and re-render without a snapshot // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); // (renderInbox now lives in ./flow.js — dashboard has no // #inbox-section element to render into.) syncApprovalsFromSnapshot(s); renderApprovals(); refreshReminders(); refreshSchedules(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // approvals + questions + 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(); // Cold-load the operator inbox so the Y3R C4LL pill + browser // title reflect unread agent->operator messages immediately, before the // operator opens the tab. Live updates arrive via the broker stream. refreshOperatorInbox(); // NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html // with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/ // question notifications — it reads the browser permission + localStorage // mute flag the settings page sets, so no bind() is needed here. Panel.bind(); // ─── live updates: dashboard event stream ────────────────────────────── // 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. // // 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 the inbox ignores mutation events. 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 / meta_inputs_changed / meta_update_running are // handled on /core.html now (the SYST3M panels moved there). // rebuild_queue_changed stays: it refreshes the SW4RM badges. rebuild_queue_changed: applyRebuildQueueChanged, schedules_changed: applySchedulesChanged, reminders_changed: applyRemindersChanged, capabilities_changed: applyCapabilitiesChanged, tool_groups_changed: applyToolGroupsChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share // one upstream SSE connection — see docs/web-ui.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. const es = openStream('/api/dashboard/stream'); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } // Broker `sent` frames aren't mutation events, but the operator // inbox cares about ones addressed to "operator". if (ev.kind === 'sent' && ev.to === 'operator') { operatorInboxAppendFromEvent(ev); 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` / `#call` / `#system` / `#permissions` / // `#schedules` activate the matching pane on the dashboard. Empty // hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs — // they're separate pages (`/flow.html`, `/settings.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())); // Re-fetch on activation as a safety net: SSE covers live mutations, // re-sync covers disconnect windows / approval-path inserts that // don't yet emit. SCH3DUL3S also re-fetches reminders (same tab). if (target === 'schedules') { refreshSchedules(); refreshReminders(); } // 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(); } if (target === 'call') { refreshOperatorInbox(); } } // ST4TS (hive-wide turn-stats rollup) moved to its own page, // `/stats.html` — the render JS + the window selector live in // stats.js now. The dashboard no longer fetches // /api/stats-hive. // 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 }); // Register the Y3R C4LL domain's count callback (call.js) — its live // mutations (inbox stream append, mark-read) trigger a tab-count refresh // through this instead of reaching back into the coordinator directly. initCall({ onCountsChanged: refreshTabCounts, onContainersDirty: renderContainersFromState }); // 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. // The operator inbox (unread agent->operator messages) now lives in // call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and // `operatorInboxCount` are imported above. It calls back through the // `onCountsChanged` callback registered via `initCall` at boot. 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); // Y3R C4LL — pending approvals + operator-targeted questions + // unread agent->operator messages. const callCount = activeApprovalCount() + activeQuestionCount() + operatorInboxCount(); setTabCount('call', callCount); // Browser tab title prefix — lets the operator see the pending // call count without switching to the window. Strips any existing // `(N) ` prefix before re-applying so identity-title updates // (which run once on state load, not every tick) compose cleanly. const rawTitle = document.title.replace(/^\(\d+\) /, ''); document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle; // SCH3DUL3S — count of schedules with at least one still-active // target (whole-schedule cancellation or all-targets-cancelled // means "not waiting on the worker"; those don't pull attention). setTabCount('schedules', activeScheduleCount()); } // 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. })();