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
315 lines
14 KiB
JavaScript
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.
|
|
})();
|