Per mara's steer on #3053 ("chip/pill/badge is the same if you squint ... theme unification is part of the goal" then "make it common css instead of component, thats fine. but make them look unified (not as much per usage css)"): shared CSS, not a JS component. New @hive/shared/pill.css defines two classes, `.hive-pill` (primary state chips) and `.hive-pill-sm` (secondary meta chips) — border/ border-radius/padding/font-size/letter-spacing (colour stays per-site, the meaningful semantic part). Every render call site across dashboard (swarm.js/core.js/builds.js) and agent (index.html/app.js) now carries one of the two shared classes directly, alongside its own existing semantic-colour modifier class. Second cut of this PR, per argus's approve + mara's follow-up review comment on the first cut: the first version instead enumerated every legacy classname (`.badge`, `.status-badge`, `.header-pill`, etc.) straight into pill.css's own selector groups so no call sites needed touching. Mara's correction: that just relocates the duplication rather than removing it, and the shared CSS shouldn't have to keep naming every consumer. This version does the real rename instead. Most visible consequence, unchanged from the first cut: dashboard's `.badge` family moves off its own shape (2px square corners, uppercase, tighter padding) onto the shared rounded-pill shape + agent's "sm" tier sizing. `npm run build` clean across all three packages; verified the compiled bundles carry the new classnames at every call site (dashboard JS, agent index.html + app.js), not just the source tree. Fixes #3053
545 lines
22 KiB
JavaScript
545 lines
22 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 <hive-tab-strip>
|
|
// (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 { $, 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' });
|
|
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: '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; }
|
|
}
|
|
|
|
// ─── 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: 'hive-pill-sm ' + (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 });
|
|
},
|
|
// 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' },
|
|
{ id: 'infra', label: '1NFR4' },
|
|
],
|
|
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();
|
|
|
|
// 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();
|