hyperhive/frontend/packages/dashboard/src/core.js
iris 1acc271108 fix(#2443): address asyncBtn review notes
- asyncBtn now returns fn().finally(...) so callers can await/chain it
- Move re-fetch calls inside try/catch in core.js and permissions.js so
  network errors from fetchAndRenderStalePerms / fetchAndRender* are
  caught instead of escaping as unhandled rejections
- clearStaleAgent returns the asyncBtn promise so the function is
  properly awaitable when a button is present
- Update asyncBtn doc comment to reflect the return-value contract
2026-07-20 19:59:45 +02:00

377 lines
15 KiB
JavaScript

// C0R3 page entry (/core.html). The host/coordinator surface carved out
// of the dashboard's old SYST3M tab into its own standalone page so the
// dashboard tab strip stays lean. Two sub-tabs via the shared
// createTabStrip (default = K3PT ST4T3): kept state (tombstones) and
// container load.
//
// Rebuild queue + meta inputs have moved to /builds.html (the build
// lifecycle hub). 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 { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { createTabStrip } from '@hive/shared/tabs.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';
};
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),
el('span', { class: 'badge badge-muted' }, 'destroyed'),
);
if (t.has_creds) head.append(el('span', { class: 'badge 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' });
const respawn = el('form', {
method: 'POST', action: '/api/request-spawn',
class: 'inline', 'data-async': '',
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
});
respawn.append(
el('input', { type: 'hidden', name: 'name', value: t.name }),
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
);
actions.append(respawn);
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: 'badge 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;
}
function renderContainerLoad(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;
}
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
+ '<th>peak</th><th>limit</th><th>disk</th></tr></thead>';
const tb = document.createElement('tbody');
for (const r of rows) {
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);
tb.append(tr);
}
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; }
}
// ─── infra containers (start/stop/restart the 4 hive infra containers) ────
let infraTimer = null;
function renderInfraContainers(rows) {
const root = $('infra-containers-section');
if (!root) return;
root.replaceChildren();
if (!Array.isArray(rows) || !rows.length) {
root.append(el('p', { class: 'meta' }, 'no infra container data'));
return;
}
const ul = el('ul', { class: 'containers' });
for (const c of rows) {
const li = el('li', { class: 'container-row' });
const head = el('div', { class: 'head' });
head.append(
el('span', { class: 'name' }, c.name),
el('span', { class: 'badge ' + (c.running ? 'badge-ok' : 'badge-fail') },
c.running ? 'running' : 'stopped'),
);
li.append(head);
const actions = el('div', { class: 'actions' });
const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/';
if (c.running) {
actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?'));
actions.append(form(base + 'stop', 'btn-stop', '■ ST0P',
'stop ' + c.name + '?'));
} else {
actions.append(form(base + 'start', 'btn-start', '▶ ST4RT',
'start ' + c.name + '?'));
}
li.append(actions);
ul.append(li);
}
root.append(ul);
}
async function refreshInfraContainers() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderInfraContainers(s.infra_containers || []);
} catch (e) {
const root = $('infra-containers-section');
if (root) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e));
}
}
}
function startInfraPolling() {
refreshInfraContainers();
if (infraTimer) return;
infraTimer = setInterval(refreshInfraContainers, 5000);
}
function stopInfraPolling() {
if (infraTimer) { clearInterval(infraTimer); infraTimer = 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 });
},
// 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).
createTabStrip(document.getElementById('core-tabbar'), {
defaultId: 'kept',
onShow: (id) => {
if (id === 'load') startContainerLoadPolling();
else stopContainerLoadPolling();
if (id === 'infra') startInfraPolling();
else stopInfraPolling();
// 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();
const es = openStream('/api/dashboard/stream');
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();