From beb28d5c378950887fcf234313a941d05ed77353 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 17:46:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(builds):=20add=20/builds.html=20=E2=80=94?= =?UTF-8?q?=20build=20lifecycle=20hub=20(closes=20#1999)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone page /builds.html consolidating rebuild queue, meta inputs, and build log history into one place, with a new 'Builds' home tile linking to it. Addresses mara's request: new sub-page with a new home tile, all three items moved there. Changes: - builds.html: new page with three sub-tabs: R3BU1LD QU3U3, M3T4 1NPUTS, BUILD L0GS. Same minimal-chrome header + createTabStrip pattern as core and logs pages. - builds.js: new bundle combining rebuild queue renderer (from core.js), meta inputs renderer (from core.js), rebuild-live-log renderer (from core.js), and build log history renderer (from logs.js). Deep-links to /builds.html?id=N#buildlogs. Count pill id: builds-tab-count-rebuild. BUILD L0GS tab lazy-loads on first activation. - builds.css: @imports system-sections.css (rebuild queue + meta inputs + live-log styles) and logs.css (build-logs-* component styles). - build.mjs: register builds.js, builds.css, builds.html. - core.html: remove R3BU1LD QU3U3 + M3T4 1NPUTS tabs (now on builds.html). Default tab changes to K3PT ST4T3. - core.js: remove renderMetaInputs, renderRebuildQueue + helpers, renderRebuildLiveLog + live-log state, elapsed-time tickers, updateRebuildCount, and the rebuild_queue/meta_inputs SSE handlers. Remove openBuildLogStream + util imports no longer needed. - core.css: remove .rebuild-live-log-* rules (moved to system-sections.css so builds.css can share them via @import). - system-sections.css: add .rebuild-live-log-* styles (moved from core.css); update comment to mention builds.html. - logs.html: remove BUILD tab + pane (moved to builds.html). - logs.js: remove fetchBuild(), fmtTs, fmtDuration, openBuildLogStream import, and rebuild_queue_changed SSE debounce. Default tab: 'agent'. SSE stream retained for audit_entry_added live-appends. - index.html: add Builds tile (๐Ÿ”จ, rebuild queue ยท meta inputs ยท build logs); update Core tile desc to 'kept state ยท container load'; update Logs tile desc to remove 'build'. --- frontend/packages/dashboard/build.mjs | 22 +- frontend/packages/dashboard/src/builds.css | 26 + frontend/packages/dashboard/src/builds.html | 84 +++ frontend/packages/dashboard/src/builds.js | 632 ++++++++++++++++++ frontend/packages/dashboard/src/core.css | 88 +-- frontend/packages/dashboard/src/core.html | 44 +- frontend/packages/dashboard/src/core.js | 444 +----------- frontend/packages/dashboard/src/index.html | 12 +- frontend/packages/dashboard/src/logs.html | 20 +- frontend/packages/dashboard/src/logs.js | 223 +----- .../dashboard/src/system-sections.css | 91 ++- 11 files changed, 896 insertions(+), 790 deletions(-) create mode 100644 frontend/packages/dashboard/src/builds.css create mode 100644 frontend/packages/dashboard/src/builds.html create mode 100644 frontend/packages/dashboard/src/builds.js diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 551360d6..6dc72e37 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -5,25 +5,29 @@ // dist/dashboard.html the operator dashboard SPA โ€” served // 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/logs.html served at GET /logs.html (AGENT/SYSTEM/AUDIT) +// dist/core.html served at GET /core.html (C0R3: kept +// state / container load) +// dist/builds.html served at GET /builds.html (BU1LDS: +// rebuild queue / meta inputs / build logs) // dist/static/home.js index.html (H0M3) entry โ€” menu tiles + // matrix-tile gating + identity line // dist/static/tabs.js dashboard.html entry โ€” tab renderers + // tab routing + refreshState // dist/static/flow.js /flow.html entry โ€” broker terminal + // @-mention composer -// dist/static/logs.js /logs.html entry โ€” build/agent/system +// dist/static/logs.js /logs.html entry โ€” agent/system/audit // log viewer sub-tabs -// dist/static/{home,tabs,flow,logs}.js.map source map siblings +// dist/static/builds.js /builds.html entry โ€” rebuild queue, +// meta inputs, build log history +// dist/static/{home,tabs,flow,logs,builds}.js.map source map siblings // dist/static/common.css loaded by every page (@hive/shared // imports + shared typography/badges/ // buttons/inbox/side-panel) // dist/static/dashboard.css dashboard.html only (dashboard-specific) // dist/static/flow.css /flow.html only (flow chrome + composer) // dist/static/logs.css /logs.html only (log viewer sub-tabs) +// dist/static/builds.css /builds.html only (build lifecycle hub) // dist/static/home.css index.html (H0M3) only (tile grid) // // Each JS entry bundles `./common.js` (DOM helpers, Panel singleton, @@ -55,7 +59,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'), src('core.js'), src('matrix-accounts.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('builds.js'), src('matrix-accounts.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -94,7 +98,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', 'core.css', 'matrix-accounts.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', 'builds.css', 'matrix-accounts.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -104,7 +108,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', 'core.html', 'matrix-accounts.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'builds.html', 'matrix-accounts.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/builds.css b/frontend/packages/dashboard/src/builds.css new file mode 100644 index 00000000..e06f16e1 --- /dev/null +++ b/frontend/packages/dashboard/src/builds.css @@ -0,0 +1,26 @@ +/* โ”€โ”€โ”€ /builds.html โ€” BU1LDS page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + The build lifecycle hub: rebuild queue + live log, meta inputs, and + build log history on one page. Carved out of /core.html (rebuild + queue + meta inputs) and /logs.html (BUILD tab). + + Imports system-sections.css for rebuild queue + meta inputs + live-log + component styles, and logs.css for the build-logs-* component styles. + Body-scoped `.logs-shell` rules in logs.css don't apply here + (`.builds-shell` body); only the build-logs-* component rules are used. */ +@import "./system-sections.css"; +@import "./logs.css"; + +body.builds-shell { + margin: 0; + padding: 0; +} + +.builds-tabbar { + flex: 1; +} + +.builds-main { + padding: 1.2em 1.5em 2em; +} + +.builds-pane[hidden] { display: none; } diff --git a/frontend/packages/dashboard/src/builds.html b/frontend/packages/dashboard/src/builds.html new file mode 100644 index 00000000..c0ad63ed --- /dev/null +++ b/frontend/packages/dashboard/src/builds.html @@ -0,0 +1,84 @@ + + + + + + hyperhive // BU1LDS + + + + + + + + + + + +
+ + +
+

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โ€ฆ

+
+
+ + +
+

all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.

+
+ +
+

loadingโ€ฆ

+
+ +
+ + + + diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js new file mode 100644 index 00000000..c79781c8 --- /dev/null +++ b/frontend/packages/dashboard/src/builds.js @@ -0,0 +1,632 @@ +// BU1LDS page entry (/builds.html). The build lifecycle hub โ€” rebuild +// queue, live build log, meta inputs, and build log history on one page. +// Three sub-tabs via the shared createTabStrip (default = Rebuild Queue): +// - R3BU1LD QU3U3: pending + running rebuilds + live build log inline +// - M3T4 1NPUTS: nix flake input selector + meta-update trigger +// - BUILD L0GS: all-agents build log history (lazy-loaded on tab show) +// +// Carved out of /core.html (rebuild queue + meta inputs) and /logs.html +// (BUILD tab) so the full build lifecycle is in one place. The section +// renderers here are direct copies from core.js / logs.js with only the +// deep-link URL and count-pill id adjusted. + +import { $, el, form, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js'; +import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js'; +import { createTabStrip } from '@hive/shared/tabs.js'; + +// โ”€โ”€โ”€ derived state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let metaInputsState = []; +let metaUpdateRunning = false; +let rebuildQueueState = []; + +function syncFromSnapshot(s) { + metaInputsState = (s.meta_inputs || []).slice(); + metaUpdateRunning = !!s.meta_update_running; + 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: '/api/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 queue = s.rebuild_queue || []; + renderRebuildLiveLog(queue); + const root = $('rebuild-queue-section'); + if (!root) return; + + 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: '/builds.html?id=' + entry.build_log_id + '#buildlogs', + 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; +} + +// โ”€โ”€โ”€ running-rebuild live log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// One persistent live-log panel for the currently-running rebuild (the queue +// runs one build at a time). Keyed to the running entry's build_log_id and +// kept in its own container (#rebuild-live-log) so the queue's row re-render +// โ€” which rebuilds rows as the build step advances โ€” never tears down the open +// SSE stream. +let liveLogEs = null; +let liveLogId = null; +let liveLogDone = false; +let liveLogCollapsed = false; + +function closeLiveLogStream() { + if (liveLogEs) { liveLogEs.close(); liveLogEs = null; } +} + +function renderRebuildLiveLog(queue) { + const root = $('rebuild-live-log'); + if (!root) return; + const running = (queue || []).find( + (e) => e.state === 'running' && e.build_log_id != null); + + if (!running) { + closeLiveLogStream(); + liveLogId = null; + liveLogDone = false; + if (!root.hidden) { root.hidden = true; root.replaceChildren(); } + return; + } + if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return; + + closeLiveLogStream(); + liveLogId = running.build_log_id; + liveLogDone = false; + root.hidden = false; + root.replaceChildren(); + + const pre = el('pre', { class: 'rebuild-live-log-output' }, ''); + pre.hidden = liveLogCollapsed; + const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live'); + const toggle = el('button', { + type: 'button', + class: 'rebuild-live-log-toggle', + 'aria-expanded': String(!liveLogCollapsed), + title: liveLogCollapsed ? 'expand live log' : 'collapse live log', + }, liveLogCollapsed ? 'โ–ธ' : 'โ–พ'); + toggle.addEventListener('click', () => { + liveLogCollapsed = !liveLogCollapsed; + pre.hidden = liveLogCollapsed; + toggle.textContent = liveLogCollapsed ? 'โ–ธ' : 'โ–พ'; + toggle.setAttribute('aria-expanded', String(!liveLogCollapsed)); + toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log'; + }); + const header = el('div', { class: 'rebuild-live-log-header' }, + toggle, ' ', + el('span', { class: 'rebuild-live-log-title' }, 'live build log โ€” '), + el('code', { class: 'rqe-agent' }, running.agent), + ' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'), + ' ', badge, ' ', + el('a', { + class: 'rebuild-live-log-raw', + href: '/api/build-logs/id/' + running.build_log_id + '/raw', + download: 'build-log-' + running.build_log_id + '.txt', + }, 'โ†“ raw'), + ); + root.append(header, pre); + + liveLogEs = openBuildLogStream(running.build_log_id, pre, { + onDone: (status) => { + liveLogDone = true; + badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail'); + badge.textContent = status; + liveLogEs = null; + }, + onError: () => { + badge.className = 'rebuild-live-log-badge rll-fail'; + badge.textContent = 'stream error'; + liveLogEs = null; + }, + }); +} + +// โ”€โ”€โ”€ rebuild-queue count pill โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function updateRebuildCount() { + const pill = $('builds-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 }); + updateRebuildCount(); +} + +// โ”€โ”€โ”€ elapsed-time tickers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +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); + +// โ”€โ”€โ”€ BUILD L0GS tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// All-agents build log history with expand-to-detail. Live builds stream in +// real time. Lazy-loaded on first tab activation; auto-refreshes (debounced +// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active. +// Deep-link: ?id=N#buildlogs auto-expands the target row. + +const buildList = $('build-list'); +const buildRefresh = $('build-refresh'); +let buildLogsLoaded = false; +let buildTabs; // set in init + +function fmtTs(unixSecs) { + if (!unixSecs) return ''; + const age = Math.floor(Date.now() / 1000) - unixSecs; + return fmtAgeSecs(Math.max(0, age)) + ' ago'; +} + +async function fetchBuild() { + if (!buildList) return; + buildList.replaceChildren(); + buildList.append(el('p', { class: 'meta' }, 'loadingโ€ฆ')); + try { + const resp = await fetch('/api/build-logs?limit=30'); + if (!resp.ok) throw new Error('http ' + resp.status); + const rows = await resp.json(); + buildList.replaceChildren(); + if (!rows || rows.length === 0) { + buildList.append(el('p', { class: 'meta' }, '(no build logs yet)')); + return; + } + const ul = el('ul', { class: 'build-logs-list' }); + for (const h of rows) { + const li = el('li', { class: 'build-logs-item', 'data-log-id': String(h.id) }); + const live = !h.status; + const ok = h.status === 'ok'; + const statusClass = live ? 'badge badge-running' : ok ? 'badge badge-ok' : 'badge badge-fail'; + const statusLabel = live ? 'live' : ok ? 'ok' : 'fail'; + const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at)); + const runtime = h.runtime_secs != null + ? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs))) + : live + ? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, 'โ€ฆ') + : el('span', { class: 'build-logs-runtime meta' }, ''); + + let durTimer = null; + if (live && h.started_at) { + const updateDur = () => { + const elapsed = Math.floor(Date.now() / 1000) - h.started_at; + runtime.textContent = fmtDuration(Math.max(0, elapsed)); + }; + updateDur(); + durTimer = setInterval(updateDur, 1000); + } + + const rowBtn = el('button', { + type: 'button', + class: 'build-logs-row-btn', + 'aria-expanded': 'false', + }, + el('span', { class: statusClass }, statusLabel), + el('span', { class: 'build-logs-agent' }, h.agent), + runtime, + el('span', { class: 'build-logs-kind' }, h.kind), + el('span', { class: 'build-logs-age meta' }, age), + el('span', { class: 'build-logs-cmdline meta' }, h.cmdline), + ); + const detail = el('div', { class: 'build-logs-detail' }); + detail.hidden = true; + + let streamEs = null; + rowBtn.addEventListener('click', async () => { + const expanded = rowBtn.getAttribute('aria-expanded') === 'true'; + rowBtn.setAttribute('aria-expanded', String(!expanded)); + detail.hidden = expanded; + if (expanded) { + if (streamEs) { streamEs.close(); streamEs = null; } + return; + } + if (detail.dataset.loaded) return; + detail.replaceChildren(); + detail.append( + el('a', { + href: '/api/build-logs/id/' + h.id + '/raw', + download: 'build-log-' + h.id + '.txt', + class: 'build-logs-dl', + }, 'โ†“ download raw'), + ); + if (live) { + const pre = el('pre', { class: 'build-logs-output build-logs-live' }, ''); + const badge = el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live'); + detail.append(badge, pre); + + streamEs = openBuildLogStream(h.id, pre, { + onDone: (status) => { + if (durTimer) { clearInterval(durTimer); durTimer = null; } + if (h.started_at) { + const elapsed = Math.floor(Date.now() / 1000) - h.started_at; + runtime.textContent = fmtDuration(Math.max(0, elapsed)); + } + badge.className = status === 'ok' ? 'badge badge-ok' : 'badge badge-fail'; + badge.textContent = status; + streamEs = null; + detail.dataset.loaded = '1'; + }, + onError: () => { + if (durTimer) { clearInterval(durTimer); durTimer = null; } + badge.textContent = 'stream error'; + badge.className = 'badge badge-fail'; + streamEs = null; + }, + }); + } else { + const pre = el('pre', { class: 'build-logs-output' }, 'fetchingโ€ฆ'); + detail.append(pre); + try { + const r2 = await fetch('/api/build-logs/id/' + h.id); + if (!r2.ok) { + pre.textContent = 'error ' + r2.status; + } else { + const full = await r2.json(); + const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n'); + pre.textContent = out || '(empty)'; + } + detail.dataset.loaded = '1'; + } catch (err) { + pre.textContent = 'fetch failed: ' + err; + } + } + }); + li.append(rowBtn, detail); + ul.append(li); + } + buildList.append(ul); + // Deep-link: ?id=N auto-expands the target row. + const deepId = new URLSearchParams(location.search).get('id'); + if (deepId) { + const target = ul.querySelector('[data-log-id="' + deepId + '"]'); + if (target) { + const btn = target.querySelector('.build-logs-row-btn'); + if (btn) { + btn.click(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + } + } catch (err) { + buildList.replaceChildren(); + buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); + } +} + +if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild); + +// โ”€โ”€โ”€ SSE handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let buildRefreshTimer = null; +const SSE_HANDLERS = { + rebuild_queue_changed(ev) { + rebuildQueueState = (ev.queue || []).slice(); + renderRebuildQueue({ rebuild_queue: rebuildQueueState }); + updateRebuildCount(); + // Auto-refresh build log list when the queue changes and BUILD L0GS is active. + if (buildTabs && buildTabs.active() === 'buildlogs') { + if (buildRefreshTimer) clearTimeout(buildRefreshTimer); + buildRefreshTimer = setTimeout(fetchBuild, 2000); + } + }, + meta_inputs_changed(ev) { + metaInputsState = (ev.inputs || []).slice(); + renderMetaInputs({ meta_inputs: metaInputsState }); + }, + meta_update_running(ev) { + metaUpdateRunning = !!ev.running; + renderMetaInputs({ meta_inputs: metaInputsState }); + }, +}; + +// โ”€โ”€โ”€ boot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function refreshState() { + try { + const resp = await fetch('/api/state'); + if (resp.ok) syncFromSnapshot(await resp.json()); + } catch { + // best-effort + } + renderAll(); +} + +async function init() { + initServerWarnings(); + bindAsyncForms(() => refreshState()); + + buildTabs = createTabStrip(document.getElementById('builds-tabbar'), { + defaultId: 'rebuild', + onShow: (id) => { + if (id === 'buildlogs' && !buildLogsLoaded) { + buildLogsLoaded = true; + fetchBuild(); + } + }, + }); + + 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(); diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css index 57509df9..f17ac635 100644 --- a/frontend/packages/dashboard/src/core.css +++ b/frontend/packages/dashboard/src/core.css @@ -1,18 +1,21 @@ /* โ”€โ”€โ”€ /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 + Standalone page for kept state (tombstones) and container load. + 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 - system-sections.css (shared with the dashboard, which references a - couple of them โ€” `.rqe-source*` โ€” from its schedules view). The + The section renderers (kept state, container load) emit the same + class names as before. Those section-internal rules live in + system-sections.css (shared with dashboard.css and builds.css, which + references `.rqe-source*` from the schedules view). The `.hive-stats-table` the container-load table reuses lives in common.css, linked directly by core.html. Importing just the section rules keeps the C0R3 bundle small instead of dragging in all of - dashboard.css. */ + dashboard.css. + + Rebuild queue + meta inputs have moved to /builds.html; the + `.rebuild-live-log-*` styles that used to live here have moved to + system-sections.css so builds.css can share them via @import. */ @import "./system-sections.css"; body.core-shell { @@ -33,70 +36,3 @@ body.core-shell { /* createTabStrip toggles the native `hidden` attribute on inactive panes; ensure it wins over any inherited display. */ .core-pane[hidden] { display: none; } - -/* โ”€โ”€โ”€ running-rebuild live log (renderRebuildLiveLog in core.js) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - Streams the currently-running rebuild's build log inline under the queue. - Hidden (native `hidden` attr) when nothing is building. */ -.rebuild-live-log { - margin-top: 0.8em; - border: 1px solid var(--purple-dim); - border-radius: 4px; - background: color-mix(in srgb, var(--crust) 70%, transparent); -} -.rebuild-live-log-header { - display: flex; - align-items: center; - gap: 0.4em; - padding: 0.4em 0.6em; - font-size: 0.85em; - border-bottom: 1px solid var(--border); -} -.rebuild-live-log-toggle { - background: transparent; - border: 0; - color: var(--muted); - cursor: pointer; - font-family: inherit; - font-size: 1em; - padding: 0 0.2em; -} -.rebuild-live-log-toggle:hover { color: var(--fg); } -.rebuild-live-log-title { color: var(--muted); } -.rebuild-live-log-raw { - margin-left: auto; - color: var(--purple); - text-decoration: none; - font-size: 0.9em; -} -.rebuild-live-log-raw:hover { text-decoration: underline; } -.rebuild-live-log-badge { - font-size: 0.75em; - font-weight: bold; - padding: 0.05em 0.5em; - border-radius: 999px; - letter-spacing: 0.05em; -} -.rebuild-live-log-badge.rll-running { - color: var(--amber); - background: color-mix(in srgb, var(--amber) 18%, transparent); -} -.rebuild-live-log-badge.rll-ok { - color: var(--green); - background: color-mix(in srgb, var(--green) 18%, transparent); -} -.rebuild-live-log-badge.rll-fail { - color: var(--red); - background: color-mix(in srgb, var(--red) 18%, transparent); -} -.rebuild-live-log-output { - margin: 0; - padding: 0.5em 0.7em; - max-height: 22em; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-word; - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; - font-size: 0.8em; - line-height: 1.4; - color: var(--fg); -} diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 118afcf9..b538a3d3 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -16,23 +16,14 @@ /core.html) carved out of the dashboard's old SYST3M tab so the dashboard tab strip stays lean. Same minimal chrome as /logs.html โ€” a `โ† home` back-link to the H0M3 hub + a - createTabStrip sub-tab nav. Default sub-tab is the rebuild queue - (the thing the operator actually watches). The section
ids - (rebuild-queue-section, meta-inputs-section, tombstones-section, - container-load-section) match what core.js's renderers target, - which are the same renderers the dashboard SYST3M tab used. --> + createTabStrip sub-tab nav. Two sub-tabs: kept state (tombstones) + and container load. Rebuild queue + meta inputs have moved to + /builds.html (the build lifecycle hub). Default tab: K3PT ST4T3. + The section
ids (tombstones-section, container-load-section) + match what core.js's renderers target. -->