diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 7c9b3160..711384ed 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -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) 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/. + // `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/ (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; }