From be8d97ded773751116c39df3a81bcb66993c8535 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 19:03:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20add=20standalone=20C0R3=20pa?= =?UTF-8?q?ge=20(system=20tab=20=E2=86=92=20its=20own=20page)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carves the dashboard's SYST3M tab content out into a standalone page at /core.html, reached from the H0M3 hub, with a createTabStrip sub-tab strip (default = Rebuild Queue): rebuild queue, meta inputs, kept state, container load. Same minimal-chrome standalone-page pattern as /logs.html (← home back-link + sub-tab nav). This is the additive first step: the new page is its own esbuild bundle that cold-loads /api/state and subscribes to /dashboard/stream for the same live rebuild_queue_changed / meta_inputs_changed / meta_update_running / tombstones_changed events the dashboard uses. The four section renderers are ported from tabs.js; the dashboard's SYST3M tab is left in place and untouched, so this PR cannot regress the existing dashboard. Removing the now-duplicate SYST3M tab + de-duplicating the renderers (into a shared module) + splitting the shared section CSS out of dashboard.css is the deliberate follow-up. core.css imports dashboard.css wholesale (transitional) so the ported sections render identically; dashboard.css does not import common.css so nothing double-loads. build.mjs gains core.js / core.css / core.html entries; no Rust change (hive-c0re serves dist/ via ServeDir, and the /dashboard/stream + /dashboard/history routes are registered ahead of the fallback). Page name "C0R3" is a placeholder pending the operator's pick — trivially renamed (the /core.html URL + the C0R3 label). --- frontend/packages/dashboard/build.mjs | 9 +- frontend/packages/dashboard/src/core.css | 36 ++ frontend/packages/dashboard/src/core.html | 92 ++++ frontend/packages/dashboard/src/core.js | 541 +++++++++++++++++++++ frontend/packages/dashboard/src/index.html | 8 + 5 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/dashboard/src/core.css create mode 100644 frontend/packages/dashboard/src/core.html create mode 100644 frontend/packages/dashboard/src/core.js diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index b0d79747..6814cd62 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -6,6 +6,9 @@ // at GET /dashboard.html // dist/flow.html served at GET /flow.html // dist/logs.html served at GET /logs.html +// dist/core.html served at GET /core.html (C0R3: rebuild +// queue / meta inputs / kept state / +// container load — the old SYST3M tab) // dist/static/home.js index.html (H0M3) entry — menu tiles + // matrix-tile gating + identity line // dist/static/tabs.js dashboard.html entry — tab renderers + @@ -52,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true }); // follow-up once asset sizes warrant it). esbuild writes each entry // to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -91,7 +94,7 @@ await build({ // so a swap replaces only it) + theme.css (the semantic derivation // layer) + common.css (shared typography, badges, buttons, inbox, side // panel) plus its own page-specific bundle. -for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css']) { +for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -101,7 +104,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', ' }); } -for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css new file mode 100644 index 00000000..50fa2c28 --- /dev/null +++ b/frontend/packages/dashboard/src/core.css @@ -0,0 +1,36 @@ +/* ─── /core.html — C0R3 page ─────────────────────────────────────── + Standalone page carved out of the dashboard's old SYST3M tab. Same + minimal-chrome pattern as /logs.html (a `← home` back-link + a + createTabStrip sub-tab strip; base tab + page-header styling comes + from @hive/shared via common.css, linked by core.html). + + The four section renderers (rebuild queue, meta inputs, kept state, + container load) are ported verbatim from the dashboard, so they emit + the exact same class names. Those section-internal rules live in + dashboard.css; rather than duplicate the (scattered) blocks here, we + @import dashboard.css wholesale — transitional. dashboard.css does + NOT @import common.css (it relies on link order), so this doesn't + double-load anything; the dashboard-only chrome rules it carries + (`body.dashboard-shell`, `.dashboard-chrome`, `.tabbar`) simply match + nothing on /core.html. The follow-up that removes the SYST3M tab can + split the shared section rules into their own stylesheet. */ +@import "./dashboard.css"; + +body.core-shell { + margin: 0; + padding: 0; +} + +/* Fill the header row next to the ← home back-link, same as the logs + sub-tab strip. */ +.core-tabbar { + flex: 1; +} + +.core-main { + padding: 1.2em 1.5em 2em; +} + +/* createTabStrip toggles the native `hidden` attribute on inactive + panes; ensure it wins over any inherited display. */ +.core-pane[hidden] { display: none; } diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html new file mode 100644 index 00000000..4a56f22c --- /dev/null +++ b/frontend/packages/dashboard/src/core.html @@ -0,0 +1,92 @@ + + + + + + hyperhive // C0R3 + + + + + + + + + + + +
+ + +
+

pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.

+
+

loading…

+
+
+ + +
+

select inputs to nix flake update in /meta/. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual rebuilt system event.

+
+

loading…

+
+
+ + +
+

kept state from previously tombstoned agents — recreating an agent with the same name reuses it.

+
+

loading…

+
+
+ + +
+

live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see docs/network.md).

+
+

loading…

+
+
+ +
+ + + + diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js new file mode 100644 index 00000000..32050261 --- /dev/null +++ b/frontend/packages/dashboard/src/core.js @@ -0,0 +1,541 @@ +// 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 } 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' + + 'peaklimit'; + 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); + 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 ───────────────────────────────────────────────────────────────── +async function init() { + initServerWarnings(); + + // 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(); + }, + }); + + try { + const resp = await fetch('/api/state'); + if (resp.ok) { + syncFromSnapshot(await resp.json()); + } + } catch { + // best-effort: the page still renders its empty states + } + renderAll(); + + 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(); diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 64021b66..590624df 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -68,6 +68,14 @@ operator-local prefs · browser notifications + + + + Core + + rebuild queue · meta inputs · kept state · container load + +