hyperhive/frontend/packages/dashboard/src/core.js
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

548 lines
20 KiB
JavaScript

// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean. Two sub-tabs
// via <hive-tab-strip> (default = K3PT ST4T3): kept state (tombstones)
// and container load.
//
// Rebuild queue + meta inputs live at /builds.html (the build lifecycle
// hub), not here. This page is its own esbuild bundle with its own
// runtime: it cold-loads /api/state and subscribes to /dashboard/stream
// (the same broker event channel the dashboard uses), maintaining its
// own copy of the tombstones state.
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import "@hive/shared/hive-tab-strip.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
// ContainerView list from the last /api/state snapshot — used by renderContainerLoad
// to splice in the configured cpu_quota/memory_max limits alongside live cgroup data.
let containersState = [];
function syncFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
containersState = (s.containers || []).slice();
}
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!s.tombstones || !s.tombstones.length) {
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
const fmtBytes = (n) => {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el("hive-warn", { level: "warning" });
warn.append(
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el("ul", { class: "containers" });
for (const t of s.tombstones) {
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el("span", { class: "name" }, t.name),
// Was `destroyed`, which the backend cannot actually know — see the
// caveat above. `offline` is what the absence of a container proves.
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
li.append(head);
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit capability / tool-group JSON entries but no live
// container AND no kept-state tombstone (e.g. renamed agents like the old
// "root" manager name). Ghost detection is server-side via
// GET /api/permissions/stale so the client doesn't need to maintain a
// container-roster cache or perform set arithmetic. Lazy-loaded on first
// "kept" tab activation; refreshed when perm data changes via SSE.
let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
);
root.append(
el(
"p",
{ class: "meta" },
"agents with explicit capability or tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) {
const li = el("li", { class: "tombstones-stale-row" });
li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el(
"button",
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit capability and tool-group entries for " + name,
},
"✕ clear perms",
);
btn.addEventListener("click", () =>
asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false;
}
}),
);
li.append(btn);
ul.append(li);
}
root.append(ul, errP);
}
// Ghost detection is entirely server-side: GET /api/permissions/stale
// returns the computed list of agent names that have explicit JSON entries
// but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() {
const root = $("tombstones-stale-perms");
if (!root) return;
try {
const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderStalePerms(root, data.stale || []);
} catch (err) {
root.replaceChildren();
root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
}
stalePermsLoaded = true;
}
// ─── container load (live cgroup poll while the LOAD sub-tab is open) ──────
let containerLoadTimer = null;
function cloadFmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return "0";
const u = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i += 1;
}
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i];
}
function cloadMeter(pct) {
const p = Math.max(0, Math.min(100, pct));
const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : "";
const m = document.createElement("span");
m.className = "cload-meter";
m.title = p.toFixed(0) + "%";
const fill = document.createElement("span");
fill.className = "fill" + (cls ? " " + cls : "");
fill.style.width = p + "%";
m.append(fill);
return m;
}
// Stash the last load rows so re-renders triggered by SSE state updates can
// call renderContainerLoad(lastLoadRows) without waiting for the next poll.
let lastLoadRows = [];
function renderContainerLoad(rows) {
lastLoadRows = rows;
const root = $("container-load-section");
if (!root) return;
if (!Array.isArray(rows) || rows.length === 0) {
root.replaceChildren();
const p = document.createElement("p");
p.className = "meta";
p.textContent = "no running agent containers";
root.append(p);
return;
}
// Build a name→ContainerView lookup so we can splice in the configured
// cpu_quota / memory_max limits (set in meta/resource-limits.json and
// resolved by the server) next to the live cgroup readings.
const cvByName = new Map(containersState.map((c) => [c.name, c]));
const table = document.createElement("table");
table.className = "hive-stats-table";
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView
// (effective drop-in values, take effect on next start/restart).
// "limit" remains the live cgroup memory ceiling from /api/container-resources.
table.innerHTML =
"<thead><tr><th>agent</th><th>cpu</th><th>memory</th>" +
"<th>peak</th><th>limit</th><th>disk</th>" +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' +
"<th></th></tr></thead>";
const tb = document.createElement("tbody");
for (const r of rows) {
const cv = cvByName.get(r.name);
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = r.name;
tr.append(name);
const cpu = document.createElement("td");
cpu.className = "num";
cpu.append(
(Number(r.cpu_pct) || 0).toFixed(1) + "%",
cloadMeter(Number(r.cpu_pct) || 0),
);
tr.append(cpu);
const mem = document.createElement("td");
mem.className = "num";
const memCur = Number(r.mem_current_bytes) || 0;
if (r.mem_max_bytes) {
mem.append(
cloadFmtBytes(memCur),
cloadMeter((100 * memCur) / r.mem_max_bytes),
);
} else {
mem.textContent = cloadFmtBytes(memCur);
}
tr.append(mem);
const peak = document.createElement("td");
peak.className = "num";
peak.textContent = r.mem_peak_bytes
? cloadFmtBytes(Number(r.mem_peak_bytes))
: "—";
tr.append(peak);
const lim = document.createElement("td");
lim.className = "num";
lim.textContent = r.mem_max_bytes
? cloadFmtBytes(Number(r.mem_max_bytes))
: "∞";
tr.append(lim);
// Disk: on-disk footprint (state dir + container writable rootfs, shared
// nix store excluded). Sampled out-of-band every few minutes server-side,
// so it's null until the first sample lands — show an em-dash then.
const disk = document.createElement("td");
disk.className = "num";
disk.title =
"state dir + container writable rootfs (shared nix store excluded); sampled every few minutes";
disk.textContent =
r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "—";
tr.append(disk);
// Configured CPU / memory caps from ContainerView (resolved effective
// values: per-agent override when set, hive-wide default otherwise).
const cpuCap = document.createElement("td");
cpuCap.className = "num cload-cap";
cpuCap.title = "configured ceiling — takes effect on next start";
cpuCap.textContent = cv?.cpu_quota || "—";
tr.append(cpuCap);
const memCap = document.createElement("td");
memCap.className = "num cload-cap";
memCap.title = "configured ceiling — takes effect on next start";
memCap.textContent = cv?.memory_max || "—";
tr.append(memCap);
// S3T button toggles the inline edit row for this agent.
const actTd = document.createElement("td");
const setBtn = document.createElement("button");
setBtn.type = "button";
setBtn.className = "btn btn-sm cload-set-btn";
setBtn.textContent = "S3T";
setBtn.title = "set CPU / memory cap for " + r.name;
tr.append(actTd);
actTd.append(setBtn);
tb.append(tr);
// Inline edit row (hidden by default, toggled by the S3T button).
const editRow = document.createElement("tr");
editRow.className = "cload-edit-row";
editRow.hidden = true;
const editTd = document.createElement("td");
editTd.colSpan = 9;
editTd.className = "cload-edit-cell";
const editForm = document.createElement("form");
editForm.className = "cload-edit-form";
editForm.addEventListener("submit", async (e) => {
e.preventDefault();
const cpuInput = editForm.querySelector(".cload-cpu-input");
const memInput = editForm.querySelector(".cload-mem-input");
const errSpan = editForm.querySelector(".cload-edit-err");
const submitBtn = editForm.querySelector('[type="submit"]');
errSpan.hidden = true;
submitBtn.disabled = true;
try {
const body = new URLSearchParams({
cpu_quota: cpuInput.value.trim(),
memory_max: memInput.value.trim(),
});
const resp = await fetch(
"/api/resource-limits/" + encodeURIComponent(r.name),
{ method: "POST", body },
);
if (!resp.ok) {
errSpan.textContent = await resp
.text()
.catch(() => "error " + resp.status);
errSpan.hidden = false;
return;
}
// Success: collapse the edit row. The SSE rescan will push updated
// ContainerView data (cpu_quota/memory_max) to containersState,
// triggering a re-render of the cap columns via refreshContainerLoad.
editRow.hidden = true;
setBtn.textContent = "S3T";
} catch (err) {
errSpan.textContent = String(err);
errSpan.hidden = false;
} finally {
submitBtn.disabled = false;
}
});
const cpuLabel = document.createElement("label");
cpuLabel.className = "cload-edit-label";
cpuLabel.textContent = "cpu quota";
const cpuInput = document.createElement("input");
cpuInput.type = "text";
cpuInput.className = "cload-cpu-input";
cpuInput.placeholder = cv ? cv.cpu_quota : "e.g. 200%";
cpuInput.title =
'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
cpuLabel.append(cpuInput);
const memLabel = document.createElement("label");
memLabel.className = "cload-edit-label";
memLabel.textContent = "mem max";
const memInput = document.createElement("input");
memInput.type = "text";
memInput.className = "cload-mem-input";
memInput.placeholder = cv ? cv.memory_max : "e.g. 8G";
memInput.title =
'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
memLabel.append(memInput);
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.className = "btn btn-restart cload-save-btn";
submitBtn.textContent = "S4V3";
const hintSpan = document.createElement("span");
hintSpan.className = "meta cload-edit-hint";
hintSpan.textContent = "↺ restart to apply to a running container";
const errSpan = document.createElement("span");
errSpan.className = "cload-edit-err";
errSpan.hidden = true;
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
editTd.append(editForm);
editRow.append(editTd);
tb.append(editRow);
setBtn.addEventListener("click", () => {
const open = !editRow.hidden;
editRow.hidden = open;
setBtn.textContent = open ? "S3T" : "✕";
});
}
table.append(tb);
root.replaceChildren(table);
}
async function refreshContainerLoad() {
try {
const resp = await fetch("/api/container-resources");
if (!resp.ok) throw new Error("http " + resp.status);
renderContainerLoad(await resp.json());
} catch (e) {
const root = $("container-load-section");
if (root) {
root.replaceChildren();
const p = document.createElement("p");
p.className = "meta";
p.textContent = "container load fetch failed: " + e;
root.append(p);
}
}
}
function startContainerLoadPolling() {
refreshContainerLoad();
if (containerLoadTimer) return;
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
}
function stopContainerLoadPolling() {
if (containerLoadTimer) {
clearInterval(containerLoadTimer);
containerLoadTimer = null;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() {
renderTombstones({ tombstones: tombstonesState });
}
// ─── live SSE handlers ───────────────────────────────────────────────────
const SSE_HANDLERS = {
tombstones_changed(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
// When a container's state changes (e.g. after resource-limits update
// triggers rescan_containers_and_emit), update containersState in place
// so the cap columns in the LOAD table reflect the new configured values.
container_state_changed(ev) {
if (!ev.container) return;
const idx = containersState.findIndex((c) => c.name === ev.container.name);
if (idx >= 0) {
containersState[idx] = ev.container;
} else {
containersState.push(ev.container);
}
if (lastLoadRows.length) renderContainerLoad(lastLoadRows);
},
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
capabilities_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
// cold load and as the post-submit refresh for `data-async` forms whose
// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
// purge actions that opt out of `data-no-refresh`).
async function refreshState() {
try {
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort: the page keeps its last-rendered state
}
renderAll();
}
async function init() {
initServerWarnings();
// `data-async` form submit interceptor — without this the meta-update /
// cancel / respawn / purge buttons POST natively and the browser navigates
// to the bare `ok` response page.
bindAsyncForms(() => refreshState());
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server).
document.getElementById("core-tabbar").configure({
tabs: [
{ id: "kept", label: "K3PT ST4T3" },
{ id: "load", label: "C0NT41N3R L04D" },
],
defaultId: "kept",
onShow: (id) => {
if (id === "load") startContainerLoadPolling();
else stopContainerLoadPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed.
if (id === "kept") fetchAndRenderStalePerms();
},
});
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// 17 wire kinds down to the 4 this page 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=tombstones_changed,container_state_changed," +
"capabilities_changed,tool_groups_changed",
);
if (es) {
es.onmessage = (e) => {
let ev;
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};
}
}
init();