Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/dashboard/src/tabs.js
atlas 78412b3d3b hive: remove the read-only schedules page and the old schedule store
Scheduled prompts live at swarm level (swarm-controller's
/api/schedules, `swarmctl schedule`, the swarm UI's /schedules page).
The hive dashboard's SCH3DUL3S tab only listed the rows left in
hive-c0re's `scheduled_prompts` table so operators could copy them
over; the operator confirmed everything is migrated.

Removed:
- the SCH3DUL3S tab, its pane, schedules.js, its CSS (including the
  `.rqe-source*` rules, whose only emitter was schedules.js) and the
  hub tile's "schedules" entry
- hive-c0re's GET /api/schedules and the `scheduled_prompts` store
  (with its v1 column migration and test)
- hive-sh4re's `schedule` wire types, which only that route used
- docs describing the tab, the route and the two tables

The rebuild-queue cancel route that shared the module moves to
dashboard/rebuild_queue.rs under an OpenAPI tag of its own.

The two sqlite tables are not dropped: nothing opens them any more,
and persistence.md says they can be dropped by hand.

Closes #4916
2026-10-13 01:45:12 +02:00

315 lines
14 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, 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",
];
// <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;
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.
})();