Surfaces the per-container on-disk footprint the c0re sampler now reports as `disk_bytes` on /api/container-resources (state dir + container writable rootfs, shared nix store excluded; sampled out-of-band every few minutes). Renders bytes→human via the existing cloadFmtBytes helper, with an em-dash until the first sample lands (disk_bytes is null then). Pairs with the hive-c0re sampler half.
559 lines
21 KiB
JavaScript
559 lines
21 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. Four sub-tabs via the shared
|
|
// createTabStrip (default = Rebuild Queue): rebuild queue, meta inputs,
|
|
// kept state (tombstones), container load.
|
|
//
|
|
// 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 copies of the derived
|
|
// state. The four section renderers below are ported from tabs.js (the
|
|
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
|
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
|
|
|
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
|
|
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
|
|
|
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
|
let metaInputsState = [];
|
|
let metaUpdateRunning = false;
|
|
let tombstonesState = [];
|
|
let rebuildQueueState = [];
|
|
|
|
function syncFromSnapshot(s) {
|
|
metaInputsState = (s.meta_inputs || []).slice();
|
|
metaUpdateRunning = !!s.meta_update_running;
|
|
tombstonesState = (s.tombstones || []).slice();
|
|
rebuildQueueState = (s.rebuild_queue || []).slice();
|
|
}
|
|
|
|
// ─── meta inputs ──────────────────────────────────────────────────────────
|
|
function renderMetaInputs(s) {
|
|
const root = $('meta-inputs-section');
|
|
if (!root) return;
|
|
// Snapshot ticked checkboxes before wiping so a concurrent
|
|
// MetaInputsChanged doesn't silently clear a pending selection.
|
|
const checkedInputs = new Set(
|
|
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'))
|
|
.map((cb) => cb.dataset.metaInput),
|
|
);
|
|
root.replaceChildren();
|
|
const inputs = s.meta_inputs || [];
|
|
if (!inputs.length) {
|
|
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
|
return;
|
|
}
|
|
if (metaUpdateRunning) {
|
|
root.append(el('p', { class: 'meta-update-running' },
|
|
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
|
|
+ 'watch the agent cards for per-rebuild progress.'));
|
|
}
|
|
const f = el('form', {
|
|
method: 'POST',
|
|
action: '/meta-update',
|
|
class: 'meta-inputs-form',
|
|
'data-async': '',
|
|
'data-no-refresh': '',
|
|
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
|
});
|
|
const bulk = el('div', { class: 'meta-inputs-bulk' });
|
|
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
|
|
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
|
|
bulk.append('bulk: ', selAll, ' ', selNone);
|
|
f.append(bulk);
|
|
const ul = el('ul', { class: 'meta-inputs' });
|
|
for (const inp of inputs) {
|
|
const depth = (inp.name.match(/\//g) || []).length;
|
|
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
|
|
const li = el('li');
|
|
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
|
|
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
|
|
const cb = el('input', {
|
|
type: 'checkbox',
|
|
name: 'meta_input_' + inp.name,
|
|
id,
|
|
value: inp.name,
|
|
'data-meta-input': inp.name,
|
|
});
|
|
if (checkedInputs.has(inp.name)) cb.checked = true;
|
|
const label = el('label', { for: id, title: inp.name });
|
|
label.append(cb);
|
|
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
|
|
label.append(
|
|
el('span', { class: 'meta-input-name' }, leaf), ' ',
|
|
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
|
|
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
|
|
);
|
|
if (inp.url) {
|
|
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
|
|
'· ' + truncate(inp.url, 48)));
|
|
}
|
|
li.append(label);
|
|
ul.append(li);
|
|
}
|
|
f.append(ul);
|
|
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
|
|
f.append(hidden);
|
|
const btn = el('button', {
|
|
type: 'submit',
|
|
class: 'btn btn-meta-update',
|
|
disabled: '',
|
|
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
|
|
f.append(btn);
|
|
function refreshDisabled() {
|
|
const any = f.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
|
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
|
|
else btn.setAttribute('disabled', '');
|
|
}
|
|
f.addEventListener('change', refreshDisabled);
|
|
function setAllChecked(val) {
|
|
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = val;
|
|
refreshDisabled();
|
|
}
|
|
selAll.addEventListener('click', () => setAllChecked(true));
|
|
selNone.addEventListener('click', () => setAllChecked(false));
|
|
f.addEventListener('submit', () => {
|
|
const selected = Array.from(f.querySelectorAll('input[data-meta-input]:checked'))
|
|
.map((b) => b.dataset.metaInput);
|
|
hidden.value = selected.join(',');
|
|
});
|
|
root.append(f);
|
|
}
|
|
|
|
// ─── rebuild queue ──────────────────────────────────────────────────────
|
|
const rebuildQueueRowCache = new Map();
|
|
const QUEUE_KIND_GLYPH = {
|
|
rebuild: '↻',
|
|
meta_update: '◆',
|
|
spawn: '✨',
|
|
destroy: '🗑',
|
|
restart: '↺',
|
|
startup_sweep: '⚡',
|
|
perm_change: '🔑',
|
|
};
|
|
const QUEUE_STATE_GLYPH = {
|
|
queued: '⏸',
|
|
running: '▶',
|
|
done: '✔',
|
|
failed: '✖',
|
|
cancelled: '⊘',
|
|
};
|
|
|
|
function rebuildQueueEntryFingerprint(entry, isChild) {
|
|
return JSON.stringify({
|
|
state: entry.state,
|
|
step: entry.step,
|
|
kind: entry.kind,
|
|
agent: entry.agent,
|
|
source: entry.source,
|
|
started_at: entry.started_at,
|
|
enqueued_at: entry.enqueued_at,
|
|
finished_at: entry.finished_at,
|
|
reason: entry.reason,
|
|
error: entry.error,
|
|
build_log_id: entry.build_log_id,
|
|
isChild,
|
|
});
|
|
}
|
|
|
|
function renderRebuildQueue(s) {
|
|
const root = $('rebuild-queue-section');
|
|
if (!root) return;
|
|
const queue = s.rebuild_queue || [];
|
|
|
|
if (!queue.length) {
|
|
rebuildQueueRowCache.clear();
|
|
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
|
|
return;
|
|
}
|
|
|
|
const byId = new Map(queue.map((e) => [e.id, e]));
|
|
const tops = queue.filter((e) => e.parent_id == null);
|
|
const childrenOf = new Map();
|
|
for (const e of queue) {
|
|
if (e.parent_id != null) {
|
|
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
|
|
childrenOf.get(e.parent_id).push(e);
|
|
}
|
|
}
|
|
const orphans = queue.filter((e) => e.parent_id != null && !byId.has(e.parent_id));
|
|
|
|
const orderedLis = [];
|
|
function addEntry(entry, isChild) {
|
|
const fp = rebuildQueueEntryFingerprint(entry, isChild);
|
|
const cached = rebuildQueueRowCache.get(entry.id);
|
|
let li;
|
|
if (cached && cached.fingerprint === fp) {
|
|
li = cached.el;
|
|
} else {
|
|
li = renderQueueEntry(entry, byId, isChild);
|
|
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
|
|
}
|
|
orderedLis.push(li);
|
|
}
|
|
for (const top of tops) {
|
|
addEntry(top, false);
|
|
for (const child of childrenOf.get(top.id) || []) addEntry(child, true);
|
|
}
|
|
for (const o of orphans) addEntry(o, true);
|
|
|
|
const liveIds = new Set(queue.map((e) => e.id));
|
|
for (const [id, entry] of rebuildQueueRowCache) {
|
|
if (!liveIds.has(id)) {
|
|
entry.el.remove();
|
|
rebuildQueueRowCache.delete(id);
|
|
}
|
|
}
|
|
|
|
let ul = root.querySelector('ul.rebuild-queue');
|
|
if (!ul) {
|
|
ul = el('ul', { class: 'rebuild-queue' });
|
|
root.replaceChildren(ul);
|
|
}
|
|
for (let i = 0; i < orderedLis.length; i++) {
|
|
if (ul.children[i] !== orderedLis[i]) {
|
|
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
|
}
|
|
}
|
|
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
|
}
|
|
|
|
function renderQueueEntry(entry, _byId, isChild) {
|
|
const li = el('li', {
|
|
class: 'rebuild-queue-entry rqe-' + entry.state,
|
|
'data-id': String(entry.id),
|
|
});
|
|
if (isChild) li.classList.add('rqe-child');
|
|
li.append(
|
|
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
|
|
' ',
|
|
el('span', { class: 'rqe-kind', title: entry.kind },
|
|
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
|
|
' ',
|
|
el('code', { class: 'rqe-agent' }, entry.agent),
|
|
);
|
|
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
|
if (entry.state === 'queued') {
|
|
li.append(' ', el('span', {
|
|
class: 'rqe-when',
|
|
'data-rqe-enqueued': String(entry.enqueued_at),
|
|
}, '· queued ' + fmtAgo(entry.enqueued_at)));
|
|
} else if (entry.state === 'running' && entry.started_at) {
|
|
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
|
|
li.append(' ', el('span', {
|
|
class: 'rqe-when',
|
|
'data-rqe-elapsed': String(entry.started_at),
|
|
}, '· ' + fmtElapsed(elapsed)));
|
|
} else if (entry.finished_at) {
|
|
li.append(' ', el('span', {
|
|
class: 'rqe-when',
|
|
'data-rqe-finished': String(entry.finished_at),
|
|
'data-rqe-state': entry.state,
|
|
}, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
|
|
}
|
|
if (entry.reason) {
|
|
const r = entry.reason.split('\n')[0];
|
|
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
|
}
|
|
if (entry.step) {
|
|
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
|
|
}
|
|
if (entry.build_log_id != null) {
|
|
li.append(
|
|
' ',
|
|
el('a', {
|
|
class: 'rqe-log-link',
|
|
href: '/logs.html?id=' + entry.build_log_id + '#build',
|
|
target: '_blank',
|
|
title: 'view build log #' + entry.build_log_id,
|
|
}, 'logs →'),
|
|
);
|
|
}
|
|
if (entry.error) {
|
|
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
|
}
|
|
if (entry.state === 'queued') {
|
|
const cancelForm = el('form', {
|
|
method: 'POST',
|
|
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
|
class: 'inline rqe-cancel',
|
|
'data-async': '',
|
|
'data-confirm':
|
|
`cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` +
|
|
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
|
|
});
|
|
cancelForm.append(el('button', {
|
|
type: 'submit',
|
|
class: 'rqe-cancel-btn',
|
|
title: 'cancel this queued ' + entry.kind,
|
|
'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent,
|
|
}, '✗'));
|
|
li.append(cancelForm);
|
|
}
|
|
return li;
|
|
}
|
|
|
|
// ─── 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: '/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(
|
|
'/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);
|
|
}
|
|
|
|
// ─── 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; }
|
|
}
|
|
|
|
// ─── rebuild-queue count pill ─────────────────────────────────────────────
|
|
function updateRebuildCount() {
|
|
const pill = $('core-tab-count-rebuild');
|
|
if (!pill) return;
|
|
let n = 0;
|
|
for (const e of rebuildQueueState) {
|
|
if (e.state === 'queued' || e.state === 'running') n++;
|
|
}
|
|
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
|
else { pill.hidden = true; }
|
|
}
|
|
|
|
// ─── render-all (cold load + any full re-render) ──────────────────────────
|
|
function renderAll() {
|
|
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
renderTombstones({ tombstones: tombstonesState });
|
|
updateRebuildCount();
|
|
}
|
|
|
|
// ─── elapsed-time tickers (same cadence as the dashboard) ─────────────────
|
|
setInterval(() => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
|
|
const started = parseInt(span.dataset.rqeElapsed, 10);
|
|
if (!started) continue;
|
|
span.textContent = '· ' + fmtElapsed(Math.max(0, now - started));
|
|
}
|
|
}, 1000);
|
|
setInterval(() => {
|
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) {
|
|
const enqueued = parseInt(span.dataset.rqeEnqueued, 10);
|
|
if (!enqueued) continue;
|
|
span.textContent = '· queued ' + fmtAgo(enqueued);
|
|
}
|
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) {
|
|
const finished = parseInt(span.dataset.rqeFinished, 10);
|
|
if (!finished) continue;
|
|
const state = span.dataset.rqeState || '';
|
|
span.textContent = '· ' + state + ' ' + fmtAgo(finished);
|
|
}
|
|
}, 30_000);
|
|
|
|
// ─── live SSE handlers (own copies; no SW4RM re-render on this page) ───────
|
|
const SSE_HANDLERS = {
|
|
rebuild_queue_changed(ev) {
|
|
rebuildQueueState = (ev.queue || []).slice();
|
|
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
|
updateRebuildCount();
|
|
},
|
|
meta_inputs_changed(ev) {
|
|
metaInputsState = (ev.inputs || []).slice();
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
},
|
|
meta_update_running(ev) {
|
|
metaUpdateRunning = !!ev.running;
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
},
|
|
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());
|
|
|
|
// Hash-routed sub-tab strip; default Rebuild Queue. 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: 'rebuild',
|
|
onShow: (id) => {
|
|
if (id === 'load') startContainerLoadPolling();
|
|
else stopContainerLoadPolling();
|
|
},
|
|
});
|
|
|
|
await refreshState();
|
|
|
|
const es = openStream('/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();
|