feat(#2637): rework builds.js for new DagView wire shape

- SSE live-log → 2s polling on GET /api/build-log/<node_id> ({stdout,stderr} JSON)
- DagView top-level started_at/finished_at used directly (isoToSecs); no client
  min/max derivation
- rollupState/entryKind/isoToSecs derive state+kind from NodeView[]; Done nodes
  absent from wire so fully-done DAGs disappear naturally
- rollupState([]) returns 'done' defensively for empty node arrays
- Log links and live-log panel gated on n.has_log (backend field, mirrors old
  build_log_id != null — excludes lock/noop/store-only nodes)
- Raw download: /api/build-log/<node_id>/raw (text/plain)
This commit is contained in:
iris 2026-07-23 15:20:04 +02:00 committed by mara
commit ecc2ebe682

View file

@ -19,6 +19,31 @@ let metaInputsState = [];
let metaUpdateRunning = false;
let rebuildQueueState = [];
// ─── DAG-derived helpers ──────────────────────────────────────────────────────
// DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at`
// — these are all derived from the NodeView array by the client.
// Rollup state from nodes (failed > cancelled > running > queued).
// `Done` nodes are excluded from the payload, so a fully-done DAG is absent;
// an empty nodes array should not arise in practice — return 'done' defensively.
function rollupState(nodes) {
const ns = nodes || [];
if (!ns.length) return 'done';
if (ns.some((n) => n.state === 'failed')) return 'failed';
if (ns.some((n) => n.state === 'cancelled')) return 'cancelled';
if (ns.some((n) => n.state === 'running')) return 'running';
return 'queued';
}
// Parse an RFC3339 datetime string (from DateTime<Utc>) to unix seconds.
function isoToSecs(s) {
if (!s) return null;
const ms = Date.parse(s);
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
}
function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running;
@ -120,19 +145,6 @@ function renderMetaInputs(s) {
// ─── 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: '▶',
@ -140,30 +152,7 @@ const QUEUE_STATE_GLYPH = {
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;
}
@ -310,16 +299,16 @@ function nodeComponents(nodes) {
}
function rebuildQueueEntryFingerprint(entry) {
const nodes = entry.nodes || [];
return JSON.stringify({
state: entry.state,
kind: entry.kind,
state: rollupState(nodes),
agent: entryAgents(entry),
source: entry.source,
started_at: entry.started_at,
enqueued_at: entry.enqueued_at,
finished_at: entry.finished_at,
started_at: isoToSecs(entry.started_at),
created_at: entry.created_at,
finished_at: isoToSecs(entry.finished_at),
reason: entry.reason,
nodes: (entry.nodes || []).map((n) => [n.kind, n.state, n.step, n.build_log_id, n.error]),
nodes: nodes.map((n) => [n.kind, n.state, isoToSecs(n.started_at), isoToSecs(n.finished_at), n.error]),
});
}
@ -375,62 +364,57 @@ function renderRebuildQueue(s) {
}
function renderQueueEntry(entry) {
const nodes = entry.nodes || [];
const state = rollupState(nodes);
const startedAt = isoToSecs(entry.started_at);
const finishedAt = isoToSecs(entry.finished_at);
const createdAt = isoToSecs(entry.created_at);
const li = el('li', {
class: 'rebuild-queue-entry rqe-' + entry.state,
class: 'rebuild-queue-entry rqe-' + 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-state', title: state }, QUEUE_STATE_GLYPH[state] || '?'),
' ',
el('span', { class: 'rqe-kind', title: entry.kind },
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
el('span', { class: 'rqe-kind' }, entry.source),
' ',
el('code', { class: 'rqe-agent' }, entryAgents(entry)),
);
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
if (entry.state === 'queued') {
if (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));
'data-rqe-enqueued': String(createdAt ?? ''),
}, '· queued ' + (createdAt ? fmtAgo(createdAt) : '')));
} else if (state === 'running' && startedAt) {
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - startedAt);
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-elapsed': String(entry.started_at),
'data-rqe-elapsed': String(startedAt),
}, '· ' + fmtElapsed(elapsed)));
} else if (entry.finished_at) {
} else if (finishedAt) {
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)));
'data-rqe-finished': String(finishedAt),
'data-rqe-state': state,
}, '· ' + state + ' ' + fmtAgo(finishedAt)));
}
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 || [];
// Per-node chain: every DAG node in dependency order with its own state
// glyph and a log link. The DAG's actual shape is its `deps` graph —
// render that structure (via nodeComponents) not a heuristic grouping.
// `Done` nodes are excluded from the payload by the backend, so only
// live nodes appear here; `Failed` DAGs linger until the history cap.
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);
@ -443,43 +427,43 @@ function renderQueueEntry(entry) {
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));
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
chain.append(chip);
if (n.build_log_id != null) {
// Log link — keyed by node id, fetched on demand from /api/build-log/<node_id>.
// `n.has_log` is set by the backend exactly when the node has a captured
// log (equiv to old `build_log_id != null`). Lock/noop/store-only nodes
// have has_log=false and never get a link.
if (n.has_log) {
chain.append(el('a', {
class: 'rqe-log-link rqe-node-log',
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
href: '/api/build-log/' + n.id + '/raw',
target: '_blank',
title: 'view build log #' + n.build_log_id,
title: 'download build log for ' + n.kind + ' node',
}, '⎙'));
}
});
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') {
if (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})? ` +
`cancel ${entry.source} 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),
title: 'cancel this queued ' + entry.source,
'aria-label': 'cancel queued ' + entry.source + ' for ' + entryAgents(entry),
}, '✗'));
li.append(cancelForm);
}
@ -487,51 +471,83 @@ function renderQueueEntry(entry) {
}
// ─── 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;
// One persistent live-log panel for the first currently-building node.
// Keyed to that node's id and kept in its own container (#rebuild-live-log)
// so the queue's row re-render never tears down an open poll.
//
// Build logs are fetched by polling GET /api/build-log/<node_id> (returns
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
// on transition, then done).
let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'running'
let liveLogCollapsed = false;
let liveLogPollTimer = null;
function closeLiveLogStream() {
if (liveLogEs) { liveLogEs.close(); liveLogEs = null; }
function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
}
// First (dag, node) pair with a running node that opened a build log.
// First (entry, node) pair with a running node that has a log.
// Gate on has_log so lock/noop/store-only nodes don't open a blank panel.
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 };
}
}
if (rollupState(e.nodes || []) !== 'running') continue;
const node = (e.nodes || []).find((n) => n.state === 'running' && n.has_log);
if (node) return { entry: e, node };
}
return null;
}
async function fetchAndRenderLiveLog(nodeId, pre) {
try {
const r = await fetch('/api/build-log/' + nodeId);
if (!r.ok) return;
const data = await r.json();
const text = [data.stdout, data.stderr ? '--- stderr ---\n' + data.stderr : ''].filter(Boolean).join('\n');
if (pre.textContent !== text) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.textContent = text;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
} catch { /* network blip — ignore, next poll will retry */ }
}
function renderRebuildLiveLog(queue) {
const root = $('rebuild-live-log');
if (!root) return;
const live = findLiveBuild(queue);
if (!live) {
closeLiveLogStream();
clearLiveLogPoll();
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;
// Same node, already polling — just let the timer tick (or do a final
// fetch if the node just went non-running and we haven't marked done yet).
if (liveNode.id === liveLogId) {
if (!liveLogDone && liveNode.state !== 'running') {
clearLiveLogPoll();
liveLogDone = true;
const pre = root.querySelector('.rebuild-live-log-output');
const badge = root.querySelector('.rebuild-live-log-badge');
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
if (badge) {
const ok = liveNode.state !== 'failed';
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
badge.textContent = liveNode.state;
}
}
return;
}
// New node — tear down old poll, rebuild the panel.
clearLiveLogPoll();
liveLogId = liveNode.id;
liveLogDone = false;
root.hidden = false;
root.replaceChildren();
@ -555,35 +571,21 @@ function renderRebuildLiveLog(queue) {
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).
// Label the specific node's agent, not the whole DAG's agent set.
el('code', { class: 'rqe-agent' }, liveNode.agent),
' ', el('span', { class: 'rqe-kind' },
(running.kind || 'rebuild') + ' · ' + (NODE_KIND_LABEL[liveNode.kind] || liveNode.kind)),
' ', el('span', { class: 'rqe-kind' }, running.source + ' · ' + 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',
href: '/api/build-log/' + liveNode.id + '/raw',
download: 'build-log-' + liveNode.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;
},
});
// Start polling.
fetchAndRenderLiveLog(liveNode.id, pre);
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000);
}
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
@ -592,7 +594,8 @@ function updateRebuildCount() {
if (!pill) return;
let n = 0;
for (const e of rebuildQueueState) {
if (e.state === 'queued' || e.state === 'running') n++;
const s = rollupState(e.nodes || []);
if (s === 'queued' || s === 'running') n++;
}
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
else { pill.hidden = true; }