// 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 = 'agentcpumemory' + 'peaklimitdisk'; 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();