Drop the H0M3 tile hub and the core page. The dashboard is now the index; kept state is its fifth tab. A pages menu in the chrome links flow, stats, API docs and the swarm UI. The swarm apex URL comes from services.hyperhive.swarm.domain via HIVE_SWARM_PUBLIC_URL and the new swarm_public_url field of the state snapshot.
342 lines
15 KiB
JavaScript
342 lines
15 KiB
JavaScript
// the dashboard 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
|
|
// `index.html` each load their own bundle.
|
|
// 4G3NTS (containers) domain lives in `./agents.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 {
|
|
initJobqRollup,
|
|
syncTransientsFromSnapshot,
|
|
applyRebuildQueueChanged,
|
|
applyContainerStateChanged,
|
|
applyContainerRemoved,
|
|
applyTransientSet,
|
|
applyTransientCleared,
|
|
renderContainers,
|
|
renderSelectionBar,
|
|
} from "./agents.js";
|
|
import {
|
|
initBuilds,
|
|
onBuildsTabShown,
|
|
onMetaInputsChanged,
|
|
onMetaUpdateRunning,
|
|
onRebuildQueueChanged,
|
|
rebuildCount,
|
|
syncBuildsFromSnapshot,
|
|
} from "./builds.js";
|
|
import {
|
|
applyTombstonesChanged,
|
|
keptCount,
|
|
renderKept,
|
|
syncKeptFromSnapshot,
|
|
} from "./kept.js";
|
|
import { renderLinksMenu } from "./links-menu.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 (in common.js, shared with the other
|
|
// pages' bundles). 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"];
|
|
// <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_);
|
|
});
|
|
}
|
|
|
|
// Rev line in the footer: absent when the flake ref isn't a local path
|
|
// pin (a bare `github:` url has no on-disk rev to canonicalize), so it
|
|
// stays hidden rather than showing a placeholder. `hyperhive_rev`
|
|
// canonicalizes to a nix store path (/nix/store/<hash>-<pname>); the
|
|
// part that varies between builds is the hash, so slice that out rather
|
|
// than truncating from the tail. Falls back to a plain head-slice for a
|
|
// non-store path (e.g. a bare local dir during dev).
|
|
function renderHiveRev(rev) {
|
|
const node = $("hive-rev");
|
|
if (!node || !rev) return;
|
|
const storeMatch = rev.match(/^\/nix\/store\/([^-]+)/);
|
|
node.textContent = `rev ${(storeMatch ? storeMatch[1] : rev).slice(0, 12)}`;
|
|
node.title = rev;
|
|
node.hidden = false;
|
|
}
|
|
|
|
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 4G3NTS 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 4G3NTS queue-summary banner
|
|
// (per-agent card badges are transient-only — see agents.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();
|
|
syncBuildsFromSnapshot(s);
|
|
syncKeptFromSnapshot(s);
|
|
renderContainers(s);
|
|
renderKept();
|
|
renderLinksMenu(s);
|
|
renderHiveRev(s.hyperhive_rev);
|
|
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 4G3NTS / 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 the dashboard 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: a payload-less push trigger. Refreshes the
|
|
// 4G3NTS queue-summary banner (see agents.js) and the R3BU1LD QU3U3
|
|
// tab's JobqGraph (see builds.js).
|
|
rebuild_queue_changed: () => {
|
|
applyRebuildQueueChanged();
|
|
onRebuildQueueChanged();
|
|
},
|
|
meta_inputs_changed: onMetaInputsChanged,
|
|
meta_update_running: onMetaUpdateRunning,
|
|
tombstones_changed: applyTombstonesChanged,
|
|
};
|
|
(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 8 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," +
|
|
"meta_inputs_changed,meta_update_running,tombstones_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: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
|
|
// activate the matching pane on the dashboard. Empty
|
|
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
|
|
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
|
// the pages menu (links-menu.js). 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 4G3NTS where agent cards live).
|
|
document.body.dataset.activeTab = target;
|
|
renderSelectionBar(Array.from(containersState.values()));
|
|
onBuildsTabShown(target);
|
|
}
|
|
|
|
// 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 4G3NTS), toggles the active tab/pane + aria-selected, and
|
|
// fires activateTab for the per-tab side-effects on every change.
|
|
createTabStrip($("tabbar"), { defaultId: "agents", onShow: activateTab });
|
|
// The rebuild queue mounts now, not on first show, so its tab's count pill
|
|
// is live from the start.
|
|
initBuilds();
|
|
|
|
// 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() {
|
|
// 4G3NTS — 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 stale = 0;
|
|
for (const c of containersState.values()) {
|
|
if (c.needs_update) stale++;
|
|
}
|
|
setTabCount("agents", stale);
|
|
// R3BU1LD QU3U3 — rebuilds in flight.
|
|
setTabCount("rebuild", rebuildCount());
|
|
// K3PT ST4T3 — entries of kept state.
|
|
setTabCount("kept", keptCount());
|
|
}
|
|
// 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.
|
|
})();
|