hyperhive/frontend/packages/dashboard/src/builds.js

845 lines
32 KiB
JavaScript

// 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, 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: '↺',
boot: '⚡',
perm_change: '🔑',
graceful_stop: '⏹',
start: '▶',
stop: '■',
reconcile: '⇄',
};
const QUEUE_STATE_GLYPH = {
queued: '⏸',
running: '▶',
done: '✔',
failed: '✖',
cancelled: '⊘',
};
// Short display labels for the per-DAG node kinds (the primitive ops).
const NODE_KIND_LABEL = {
prebuild: 'prebuild',
stop_for_update: 'stop',
swap: 'swap',
post_swap: 'post swap',
provision: 'provision',
create: 'create',
meta_lock: 'meta lock',
reconcile: 'reconcile',
signal: 'signal',
drain: 'drain',
write_dropin: 'dropin',
write_perm_file: 'perm file',
approval_deploy: 'deploy',
set_wanted: 'set wanted',
noop: 'noop',
};
// The currently-running node of a DAG (per-node `step` labels + build
// logs live on nodes now; the DAG's `state` is a roll-up).
function runningNode(entry) {
return (entry.nodes || []).find((n) => n.state === 'running') || null;
}
function firstFailedNode(entry) {
return (entry.nodes || []).find((n) => n.state === 'failed') || null;
}
// Distinct agents across a DAG's nodes, comma-joined for display. Agent is
// per-node now (a DAG can span agents, e.g. a hive-wide restart), so there's
// no DAG-level `agent` field — derive it from the nodes.
function entryAgents(entry) {
const seen = [];
for (const n of entry.nodes || []) {
if (n.agent && !seen.includes(n.agent)) seen.push(n.agent);
}
return seen.join(',');
}
// Further split one weakly-connected component's topo-ordered nodes on
// *fan-out* points — a node with more than one direct dependent — so a
// shared gate/lock node (e.g. MetaLock, which every agent's rebuild
// subgraph now hangs off via AfterOk since the meta-update cascade was
// folded into one in-DAG growth instead of separate per-agent child DAGs)
// doesn't merge N otherwise-independent per-agent chains into one
// wall-of-chips line. Pure
// `deps`-structure-driven, same as the WCC split above — no `agent` field
// involved. Rule: a node with out-degree > 1 renders as its own one-node
// line; each of its direct dependents becomes the root of an independent
// line, walked forward until the next fan-out point or a dead end. A
// component with no fan-out (the common single-agent case) comes back
// unchanged as one line.
function splitFanOut(orderedNodes) {
const ids = new Set(orderedNodes.map((n) => n.id));
const children = new Map(orderedNodes.map((n) => [n.id, []])); // dep -> direct dependents
for (const n of orderedNodes) {
for (const d of n.deps || []) {
if (children.has(d)) children.get(d).push(n.id);
}
}
const byId = new Map(orderedNodes.map((n) => [n.id, n]));
const indeg = new Map(orderedNodes.map((n) => [n.id, 0]));
for (const n of orderedNodes) {
for (const d of n.deps || []) {
if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1);
}
}
const roots = orderedNodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id);
const lines = [];
const visited = new Set();
function walk(startId, chain) {
let cur = startId;
while (cur != null && !visited.has(cur)) {
visited.add(cur);
const kids = children.get(cur) || [];
if (kids.length > 1) {
if (chain.length) lines.push(chain);
lines.push([byId.get(cur)]);
for (const k of kids) walk(k, []);
return;
}
chain.push(byId.get(cur));
cur = kids.length === 1 ? kids[0] : null;
}
if (chain.length) lines.push(chain);
}
for (const r of roots) walk(r, []);
// Any leftover (shouldn't happen for a DAG reachable from its roots, but
// guard against a dep loop / disconnected leftover rather than dropping
// nodes from the display).
for (const n of orderedNodes) {
if (!visited.has(n.id)) lines.push([n]);
}
return lines.length ? lines : [orderedNodes];
}
// Split a DAG's nodes into its actual weakly-connected subgraphs, using the
// `deps` edges the backend provides — not an inferred heuristic like
// grouping by `n.agent`. A DAG with independent subgraphs (e.g. a
// multi-agent restart, no cross-agent deps) naturally splits into one
// component per subgraph; a single connected DAG stays one component. Each
// component's nodes come back topo-sorted (Kahn's algorithm, falling back to
// original array order for ties) so a chain renders in actual dependency
// order rather than raw array order. Each component is then further split
// on fan-out points (see `splitFanOut`) so a shared gate node doesn't merge
// independent branches into one line.
function nodeComponents(nodes) {
if (!nodes.length) return [];
const byId = new Map(nodes.map((n) => [n.id, n]));
const adj = new Map(nodes.map((n) => [n.id, new Set()])); // undirected, for component split
for (const n of nodes) {
for (const d of n.deps || []) {
if (!byId.has(d)) continue; // dep outside this node set (shouldn't happen)
adj.get(n.id).add(d);
adj.get(d).add(n.id);
}
}
const seen = new Set();
const components = [];
for (const n of nodes) {
if (seen.has(n.id)) continue;
const compIds = [];
const stack = [n.id];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
compIds.push(id);
for (const nb of adj.get(id)) {
if (!seen.has(nb)) {
seen.add(nb);
stack.push(nb);
}
}
}
const compSet = new Set(compIds);
const compNodes = nodes.filter((cn) => compSet.has(cn.id));
// Topo-sort within the component via its actual `deps` edges (directed).
const indeg = new Map(compNodes.map((cn) => [cn.id, 0]));
for (const cn of compNodes) {
for (const d of cn.deps || []) {
if (compSet.has(d)) indeg.set(cn.id, indeg.get(cn.id) + 1);
}
}
const ordered = [];
const ready = compNodes.filter((cn) => indeg.get(cn.id) === 0);
const remaining = new Map(compNodes.map((cn) => [cn.id, cn]));
while (ready.length) {
const cn = ready.shift();
if (!remaining.has(cn.id)) continue;
remaining.delete(cn.id);
ordered.push(cn);
for (const other of compNodes) {
if ((other.deps || []).includes(cn.id) && remaining.has(other.id)) {
indeg.set(other.id, indeg.get(other.id) - 1);
if (indeg.get(other.id) === 0) ready.push(other);
}
}
}
// Any leftover (cycle, or a dep outside the node set) — append in
// original order rather than dropping nodes from the display.
for (const cn of compNodes) {
if (remaining.has(cn.id)) ordered.push(cn);
}
for (const line of splitFanOut(ordered)) components.push(line);
}
return components;
}
function rebuildQueueEntryFingerprint(entry) {
return JSON.stringify({
state: entry.state,
kind: entry.kind,
agent: entryAgents(entry),
source: entry.source,
started_at: entry.started_at,
enqueued_at: entry.enqueued_at,
finished_at: entry.finished_at,
reason: entry.reason,
nodes: (entry.nodes || []).map((n) => [n.kind, n.state, n.step, n.build_log_id, n.error]),
});
}
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;
}
// Every multi-step op is a single DAG now (its whole graph lives in
// `nodes`, split into subgraphs by the backend `deps` edges) — there are
// no cross-DAG parent/child links to group. Render each queue entry in
// enqueue order.
const orderedLis = [];
for (const entry of queue) {
const fp = rebuildQueueEntryFingerprint(entry);
const cached = rebuildQueueRowCache.get(entry.id);
let li;
if (cached && cached.fingerprint === fp) {
li = cached.el;
} else {
li = renderQueueEntry(entry);
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
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) {
const li = el('li', {
class: 'rebuild-queue-entry rqe-' + entry.state,
'data-id': String(entry.id),
});
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' }, entryAgents(entry)),
);
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)));
}
// Per-node chain: every DAG node in dependency order with its own
// state glyph, live step label, and build-log link. This is the
// node-aware render that makes queue jumps / partial progress
// visible (e.g. reconcile running while swap failed).
//
// A DAG's actual shape is its `deps` graph, not an incidental property
// like `n.agent` — render *that* structure (via nodeComponents, which
// splits on `deps` and topo-sorts each piece), not a heuristic grouping.
// A DAG made of independent subgraphs (e.g. a multi-agent restart, no
// cross-agent deps) naturally comes back as multiple components and
// gets one line each; a single connected DAG (the common case) stays
// one component and renders exactly as the old one-line chain did.
const nodes = entry.nodes || [];
if (nodes.length) {
const components = nodeComponents(nodes);
const multi = components.length > 1;
for (const compNodes of components) {
const chain = el('div', { class: 'rqe-nodes' });
if (multi) {
// Label from the component's own nodes (informational only — the
// split itself came from `deps`, not from agent).
const agents = [];
for (const n of compNodes) {
if (n.agent && !agents.includes(n.agent)) agents.push(n.agent);
}
chain.append(el('code', { class: 'rqe-node-agent-label' }, agents.join(',')));
}
compNodes.forEach((n, i) => {
if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → '));
const chip = el('span', {
class: 'rqe-node rqe-node-' + n.state,
title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''),
},
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + (NODE_KIND_LABEL[n.kind] || n.kind));
chain.append(chip);
if (n.build_log_id != null) {
chain.append(el('a', {
class: 'rqe-log-link rqe-node-log',
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
target: '_blank',
title: 'view build log #' + n.build_log_id,
}, '⎙'));
}
});
li.append(chain);
}
}
const running = runningNode(entry);
if (running && running.step) {
li.append(el('div', { class: 'rqe-step' }, '↳ ' + running.step));
}
const failed = firstFailedNode(entry);
if (failed && failed.error) {
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.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 \`${entryAgents(entry)}\` (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 ' + entryAgents(entry),
}, '✗'));
li.append(cancelForm);
}
return li;
}
// ─── running-rebuild live log ─────────────────────────────────────────────────
// One persistent live-log panel for the first currently-building node
// (build logs are per-node now; with buildSlots = 1 at most one nix
// build runs at a time). Keyed to that node'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; }
}
// First (dag, node) pair with a running node that opened a build log.
function findLiveBuild(queue) {
for (const e of queue || []) {
if (e.state !== 'running') continue;
for (const n of e.nodes || []) {
if (n.state === 'running' && n.build_log_id != null) {
return { entry: e, node: n };
}
}
}
return null;
}
function renderRebuildLiveLog(queue) {
const root = $('rebuild-live-log');
if (!root) return;
const live = findLiveBuild(queue);
if (!live) {
closeLiveLogStream();
liveLogId = null;
liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
return;
}
const { entry: running, node: liveNode } = live;
if (liveNode.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
closeLiveLogStream();
liveLogId = liveNode.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 — '),
// This header labels one specific node's log stream (`liveNode`), not
// the DAG as a whole — so it's the node's own agent, not the DAG's
// full agent set (which would mislabel a single agent's log with every
// agent once DAGs span multiple).
el('code', { class: 'rqe-agent' }, liveNode.agent),
' ', el('span', { class: 'rqe-kind' },
(running.kind || 'rebuild') + ' · ' + (NODE_KIND_LABEL[liveNode.kind] || liveNode.kind)),
' ', badge, ' ',
el('a', {
class: 'rebuild-live-log-raw',
href: '/api/build-logs/id/' + liveNode.build_log_id + '/raw',
download: 'build-log-' + liveNode.build_log_id + '.txt',
}, '↓ raw'),
);
root.append(header, pre);
liveLogEs = openBuildLogStream(liveNode.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();