// C0R3 page entry (/core.html). The host/coordinator surface, its own // standalone page so the dashboard tab strip stays lean: kept state // (tombstones). // // Rebuild queue + meta inputs are tabs of /dashboard.html, 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 { bindAsyncForms } from "@hive/shared/forms.js"; // ─── derived state (own copies; this bundle has its own runtime) ────────── let tombstonesState = []; function syncFromSnapshot(s) { tombstonesState = (s.tombstones || []).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); } // ─── 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 }); }, }; // ─── 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()); await refreshState(); // kinds= matches SSE_HANDLERS below verbatim — narrows the stream down // to the kinds 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"); 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();