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:
parent
29f45ddd48
commit
ecc2ebe682
1 changed files with 130 additions and 127 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue