feat(builds): add /builds.html — build lifecycle hub (closes #1999)

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'.
This commit is contained in:
iris 2026-06-27 17:46:08 +02:00
commit beb28d5c37
11 changed files with 896 additions and 790 deletions

View file

@ -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/<name>.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));
}

View file

@ -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; }

View file

@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // BU1LDS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/builds.css">
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
meta inputs, and build log history on one page. Carved out of the
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
tab) so the full build lifecycle is in one place. Same minimal
chrome as /core.html — a `← home` back-link + a createTabStrip
sub-tab nav. Default sub-tab is the rebuild queue. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<nav class="hive-tabbar builds-tabbar" id="builds-tabbar" role="tablist">
<a class="hive-tab" id="builds-tab-rebuild" href="#rebuild" role="tab"
aria-controls="builds-pane-rebuild" data-tab="rebuild">
<span class="builds-tab-label">R3BU1LD QU3U3</span>
<span class="tab-count" id="builds-tab-count-rebuild" hidden></span>
</a>
<a class="hive-tab" id="builds-tab-meta" href="#meta" role="tab"
aria-controls="builds-pane-meta" data-tab="meta">
<span class="builds-tab-label">M3T4 1NPUTS</span>
</a>
<a class="hive-tab" id="builds-tab-buildlogs" href="#buildlogs" role="tab"
aria-controls="builds-pane-buildlogs" data-tab="buildlogs">
<span class="builds-tab-label">BUILD L0GS</span>
</a>
</nav>
</header>
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Driven by /api/state.rebuild_queue cold-load +
live `rebuild_queue_changed` over /api/dashboard/stream. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="builds-tab-rebuild">
<p class="meta">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.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta"
role="tabpanel" aria-labelledby="builds-tab-meta">
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
Click a row to expand stdout + stderr. Live builds stream in real
time. Lazy-loaded on first tab activation; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs"
role="tabpanel" aria-labelledby="builds-tab-buildlogs">
<p class="meta">all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
</main>
<script type="module" src="/static/builds.js" defer></script>
</body>
</html>

View file

@ -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();

View file

@ -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);
}

View file

@ -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 <div> 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 <div> ids (tombstones-section, container-load-section)
match what core.js's renderers target. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<nav class="hive-tabbar core-tabbar" id="core-tabbar" role="tablist">
<a class="hive-tab" id="core-tab-rebuild" href="#rebuild" role="tab"
aria-controls="core-pane-rebuild" data-tab="rebuild">
<span class="core-tab-label">R3BU1LD QU3U3</span>
<span class="tab-count" id="core-tab-count-rebuild" hidden></span>
</a>
<a class="hive-tab" id="core-tab-meta" href="#meta" role="tab"
aria-controls="core-pane-meta" data-tab="meta">
<span class="core-tab-label">M3T4 1NPUTS</span>
</a>
<a class="hive-tab" id="core-tab-kept" href="#kept" role="tab"
aria-controls="core-pane-kept" data-tab="kept">
<span class="core-tab-label">K3PT ST4T3</span>
@ -46,31 +37,6 @@
<main class="core-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Driven by /api/state.rebuild_queue cold-load +
live `rebuild_queue_changed` over /api/dashboard/stream. Default tab. -->
<section class="core-pane" id="core-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="core-tab-rebuild">
<p class="meta">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.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in core.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section class="core-pane" id="core-pane-meta" data-tab-pane="meta"
role="tabpanel" aria-labelledby="core-tab-meta">
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
tombstones-section shows destroyed agents (revive / purge buttons).
tombstones-stale-perms is lazy-loaded on tab activation and shows

View file

@ -1,391 +1,22 @@
// 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.
// dashboard tab strip stays lean. Two sub-tabs via the shared
// createTabStrip (default = K3PT ST4T3): kept state (tombstones) and
// 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.
// Rebuild queue + meta inputs have moved to /builds.html (the build
// lifecycle hub). This page is its own esbuild bundle with its own
// runtime: it cold-loads /api/state and subscribes to /dashboard/stream
// (the same broker event channel the dashboard uses), maintaining its
// own copy of the tombstones state.
import { $, el, form, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
import { fmtAgo, fmtElapsed, truncate } from './util.js';
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.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: '/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: '/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;
}
// ─── 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. Reuses the build-log stream the logs page BUILD tab uses
// (GET /api/build-logs/id/{id}/stream; frames {stdout_append, stderr_append,
// done, status}); the stream replays accumulated output on connect, so
// attaching mid-build still shows the backlog.
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) {
// No running build with a log → tear down + hide.
closeLiveLogStream();
liveLogId = null;
liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
return;
}
// Same build still streaming (or already finished) → leave the panel as-is.
// Re-renders fire on every queue mutation; don't reconnect mid-build, and
// don't reopen a stream that already sent `done`.
if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
// New running build → (re)build the panel + open its stream.
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);
// Stream via the shared helper (same path the L0GS BUILD tab uses). It owns
// the append / sticky-scroll / stderr-separator / reconnect-replay logic; we
// just wire the badge. onDone keeps liveLogId + liveLogDone set so a
// re-render before the entry leaves 'running' won't reopen the finished
// stream — the next render with no running entry clears the panel.
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;
},
});
}
// ─── kept state (tombstones) ──────────────────────────────────────────────
@ -611,64 +242,13 @@ 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) ───────
// ─── live SSE handlers ───────────────────────────────────────────────────
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 });
@ -706,11 +286,11 @@ async function init() {
// to the bare `ok` response page.
bindAsyncForms(() => refreshState());
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server).
createTabStrip(document.getElementById('core-tabbar'), {
defaultId: 'rebuild',
defaultId: 'kept',
onShow: (id) => {
if (id === 'load') startContainerLoadPolling();
else stopContainerLoadPolling();

View file

@ -51,12 +51,20 @@
<span class="home-tile-desc">live all-agents message firehose</span>
</a>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span>
</a>
<a class="home-tile" href="/logs.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span>
</span>
<span class="home-tile-desc">build · agent · system logs · privileged-action audit</span>
<span class="home-tile-desc">agent · system logs · privileged-action audit</span>
</a>
<a class="home-tile" href="/stats.html">
@ -80,7 +88,7 @@
<span class="home-tile-icon" aria-hidden="true"></span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">rebuild queue · meta inputs · kept state · container load</span>
<span class="home-tile-desc">kept state · container load</span>
</a>
<a class="home-tile" href="/matrix-accounts.html">

View file

@ -14,14 +14,12 @@
<!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard. -->
points to the H0M3 hub (served at /), not the dashboard.
Three sub-tabs: AGENT, SYSTEM, AUDIT. Build log history has moved
to /builds.html (the build lifecycle hub). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<nav class="hive-tabbar logs-tabbar" id="logs-tabbar" role="tablist">
<a class="hive-tab" id="logs-tab-build" href="#build" role="tab"
aria-controls="logs-pane-build" data-tab="build">
<span class="logs-tab-label">BUILD</span>
</a>
<a class="hive-tab" id="logs-tab-agent" href="#agent" role="tab"
aria-controls="logs-pane-agent" data-tab="agent">
<span class="logs-tab-label">AGENT</span>
@ -39,18 +37,6 @@
<main class="logs-main">
<!-- BUILD: all-agents build log history. Lists recent nix build /
nixos-container invocations across all agents, with
click-to-expand full stdout+stderr. Backed by
GET /api/build-logs?limit=30. -->
<section class="logs-pane" id="logs-pane-build" data-tab-pane="build"
role="tabpanel" aria-labelledby="logs-tab-build">
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- AGENT: journald viewer for a specific agent container.
Agent selector + unit filter + line count. Backed by
GET /api/journal/{agent}?unit=<unit>&lines=N. -->

View file

@ -1,21 +1,11 @@
// /logs.html entry point: log viewer with four sub-tabs (BUILD, AGENT,
// SYSTEM, AUDIT).
// BUILD — all-agents build log history, backed by GET /api/build-logs
// /logs.html entry point: log viewer with three sub-tabs (AGENT, SYSTEM,
// AUDIT). Build log history has moved to /builds.html.
// AGENT — per-container journald viewer, backed by GET /api/journal/{name}
// SYSTEM — host service logs, backed by GET /api/journal-host
// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log;
// live-appends via the `audit_entry_added` /dashboard/stream event
//
// Tab routing via URL hash (#build, #agent, #system, #audit). Default: #build.
//
// Live improvements:
// - Live build output auto-scrolls to the bottom (sticky-bottom) unless
// the operator has manually scrolled up.
// - Running builds show an elapsed-time counter in the row header that
// ticks every second.
// - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed
// fires from the dashboard SSE stream, so new log entries appear without
// a manual page refresh.
// Tab routing via URL hash (#agent, #system, #audit). Default: #agent.
//
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit on
// the AGENT tab (deep-link from other pages, e.g. the per-agent ⋮ menu).
@ -23,7 +13,7 @@
// SYSTEM tabs so the operator knows how stale the output is.
import {
$, el, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings,
$, el, fmtAgeSecs, openStream, initServerWarnings,
} from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
@ -40,193 +30,6 @@ import { createTabStrip } from '@hive/shared/tabs.js';
// ─── helpers ──────────────────────────────────────────────────────────
// started_at / finished_at are unix seconds (i64), not milliseconds.
function fmtTs(unixSecs) {
if (!unixSecs) return '';
const age = Math.floor(Date.now() / 1000) - unixSecs;
return fmtAgeSecs(Math.max(0, age)) + ' ago';
}
function fmtDuration(secs) {
if (secs < 60) return secs + 's';
const m = Math.floor(secs / 60), s = secs % 60;
return m + 'm ' + s + 's';
}
// ─── BUILD tab ────────────────────────────────────────────────────────
const buildList = $('build-list');
const buildRefresh = $('build-refresh');
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) });
// status is null while running, 'ok'/'fail' when finished.
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' }, '');
// For live builds, start an elapsed-time ticker immediately in the
// row header so the operator can see how long the build has been
// running without opening the log detail.
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(); // show initial value right away
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;
}
// already loaded?
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);
// Stream via the shared helper (also used by the C0R3 rebuild-queue
// live-log panel); it owns the append / sticky-scroll /
// stderr-separator / reconnect-replay logic. We wire the badge +
// the row's elapsed-time ticker here.
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: if the URL contains ?id=N, auto-expand that log entry
// so a `logs →` link from the rebuild-queue panel drops the operator
// straight into the live output without extra clicks.
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);
// Auto-refresh the build list when the dashboard SSE stream signals that
// the rebuild queue changed — this surfaces new log entries and status
// changes without needing a manual page refresh. Debounce 2s so a burst
// of rapid queue mutations only triggers one fetch.
{
let buildRefreshTimer = null;
const debouncedRefreshBuild = () => {
if (logTabs.active() !== 'build') return;
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
};
const es = openStream('/api/dashboard/stream');
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
if (ev.kind === 'rebuild_queue_changed') debouncedRefreshBuild();
else if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
};
}
}
// ─── AGENT tab ────────────────────────────────────────────────────────
const agentSelect = $('agent-select');
@ -488,16 +291,28 @@ import { createTabStrip } from '@hive/shared/tabs.js';
// Wire the shared tab strip now that fetchSystem + the element refs it
// needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// the deep-linked tab is SYSTEM/AUDIT, kicks off the lazy fetch via onShow.
// Default: AGENT (build logs moved to /builds.html).
logTabs = createTabStrip(document.getElementById('logs-tabbar'), {
defaultId: 'build',
defaultId: 'agent',
onShow: (id) => {
if (id === 'system') fetchSystem();
else if (id === 'audit') fetchAudit();
},
});
loadAgentList();
fetchBuild();
// Subscribe to the dashboard SSE stream for audit live-appends.
{
const es = openStream('/api/dashboard/stream');
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
};
}
}
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
// accurate without a manual refresh.

View file

@ -1,18 +1,18 @@
/* SYST3M section panels shared by the dashboard + /core.html
The four SYST3M panels (rebuild queue, meta inputs, kept state,
container load) render on /core.html (their renderers live in
core.js, carved out of the dashboard in a prior refactor). These
section-internal rules used to live in dashboard.css and were
pulled in by /core.html via a wholesale `@import "./dashboard.css"`,
which dragged the entire ~39kb dashboard stylesheet into the C0R3
bundle.
/* SYST3M/BU1LDS section panels shared by dashboard, /core.html,
and /builds.html
The rebuild queue, meta inputs, kept state, and container-load panels
render on /core.html; rebuild queue + meta inputs + live-log also
render on /builds.html (the build lifecycle hub). These
section-internal rules used to live in dashboard.css and were pulled
in by /core.html via a wholesale `@import "./dashboard.css"`, which
dragged the entire ~39kb dashboard stylesheet into the bundle.
They now live here and are `@import`ed by BOTH core.css and
dashboard.css. /core.html gets only this small file instead of all
They now live here and are `@import`ed by core.css, dashboard.css,
and builds.css. Each page gets only this small file instead of all
of dashboard.css; the dashboard keeps the rules (a couple are still
referenced from dashboard JS `.rqe-source*` by the schedules view,
and `.hive-stats-table`, which already lives in common.css and is
loaded by both pages). Behaviour-preserving: identical rules, same
loaded by all pages). Behaviour-preserving: identical rules, same
custom properties from colors.css / theme.css / common.css.
The selectors here are uniquely named to these panels and are not
@ -196,6 +196,75 @@
cursor: default;
}
/* running-rebuild live log (renderRebuildLiveLog in builds.js)
Streams the currently-running rebuild's build log inline under the queue.
Hidden (native `hidden` attr) when nothing is building. Used on
/builds.html; lives here (not in builds.css) so it can be shared if
the dashboard ever gains an inline live-log view. */
.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);
}
/* SYST3M C0NT41N3R L04D: live cgroup cpu/mem
Reuses the shared `.hive-stats-table` styling (in common.css); only
the inline meter bar is new. */