Y3R C4LL is the operator inbox alone: no P3NDING APPR0VALS section, no approval cards or history, no approval_resolved subscription, no approval browser notifications, and the tab count and title prefix count unread operator messages only. The H0M3 tile text drops "approvals". The deny form was the only data-prompt form, so bindAsyncForms' prompt branch, themedPrompt and its dialog styles go too, along with the approval-only CSS (.approval*, .approvals-history, .btn-deny, the status/glyph colours and .history-note).
374 lines
17 KiB
JavaScript
374 lines
17 KiB
JavaScript
// /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, 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 { $, NOTIF, 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 { refreshSchedules, activeScheduleCount } from "./schedules.js";
|
|
import {
|
|
initCall,
|
|
refreshOperatorInbox,
|
|
operatorInboxAppendFromEvent,
|
|
operatorInboxCount,
|
|
} from "./call.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",
|
|
"schedules-section",
|
|
"capabilities-section",
|
|
"tool-groups-section",
|
|
];
|
|
// <details> 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;
|
|
// 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);
|
|
// 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);
|
|
refreshSchedules();
|
|
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();
|
|
// 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() wires the enable/mute/unmute toggle buttons that now
|
|
// live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆ section. NOTIF.show()
|
|
// (inbox notifications) reads the same browser permission +
|
|
// localStorage mute flag regardless of which page fires it — bind()
|
|
// only needs to run once, here, where the buttons live. The side
|
|
// panel (<hive-side-panel>) wires its own internals in
|
|
// connectedCallback — no bind() step needed for it.
|
|
NOTIF.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 = {
|
|
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 below verbatim, plus `sent`
|
|
// (checked separately, just above, for the operator inbox) —
|
|
// narrows this from all 12 wire kinds down to the 8 this page
|
|
// actually acts on. This page was one of 4 unfiltered
|
|
// `/api/dashboard/stream` subscribers before this (subscription
|
|
// discipline, part 1 of the dashboard-event-stream-split issue).
|
|
const es = openStream(
|
|
"/api/dashboard/stream?kinds=sent," +
|
|
"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;
|
|
}
|
|
// 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 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()));
|
|
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
|
// re-sync covers disconnect windows.
|
|
if (target === "schedules") {
|
|
refreshSchedules();
|
|
}
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
|
|
// 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);
|
|
// Y3R C4LL — unread agent->operator messages.
|
|
const callCount = 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: the ones left to recreate with `swarmctl schedule`.
|
|
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.
|
|
})();
|