feat(dashboard): node-aware queue render + buildSlots option

each queue card now shows its DAG's node chain (per-node state, step,
build-log link), fixing 'queue jumps don't show on the dashboard'.
live-log panel keys off the running node's log. new
services.hyperhive.c0re.buildSlots option (default 1) threads the
concurrent nix-build count into serve.json.
This commit is contained in:
müde 2026-07-06 20:18:55 +02:00
commit 8349e6f621
3 changed files with 129 additions and 31 deletions

View file

@ -131,6 +131,7 @@ const QUEUE_KIND_GLYPH = {
graceful_stop: '⏹', graceful_stop: '⏹',
start: '▶', start: '▶',
stop: '■', stop: '■',
reconcile: '⇄',
}; };
const QUEUE_STATE_GLYPH = { const QUEUE_STATE_GLYPH = {
queued: '⏸', queued: '⏸',
@ -139,11 +140,33 @@ const QUEUE_STATE_GLYPH = {
failed: '✖', failed: '✖',
cancelled: '⊘', 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',
create: 'create',
meta_lock: 'meta lock',
reconcile: 'reconcile',
signal: 'signal',
drain: 'drain',
write_dropin: 'dropin',
write_perm_file: 'perm file',
approval_deploy: 'deploy',
};
// 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;
}
function rebuildQueueEntryFingerprint(entry, isChild) { function rebuildQueueEntryFingerprint(entry, isChild) {
return JSON.stringify({ return JSON.stringify({
state: entry.state, state: entry.state,
step: entry.step,
kind: entry.kind, kind: entry.kind,
agent: entry.agent, agent: entry.agent,
source: entry.source, source: entry.source,
@ -151,8 +174,7 @@ function rebuildQueueEntryFingerprint(entry, isChild) {
enqueued_at: entry.enqueued_at, enqueued_at: entry.enqueued_at,
finished_at: entry.finished_at, finished_at: entry.finished_at,
reason: entry.reason, reason: entry.reason,
error: entry.error, nodes: (entry.nodes || []).map((n) => [n.kind, n.state, n.step, n.build_log_id, n.error]),
build_log_id: entry.build_log_id,
isChild, isChild,
}); });
} }
@ -257,22 +279,39 @@ function renderQueueEntry(entry, _byId, isChild) {
const r = entry.reason.split('\n')[0]; const r = entry.reason.split('\n')[0];
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
} }
if (entry.step) { // Per-node chain: every DAG node in dependency order with its own
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step)); // 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).
const nodes = entry.nodes || [];
if (nodes.length) {
const chain = el('div', { class: 'rqe-nodes' });
nodes.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);
} }
if (entry.build_log_id != null) { const running = runningNode(entry);
li.append( if (running && running.step) {
' ', li.append(el('div', { class: 'rqe-step' }, '↳ ' + running.step));
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) { const failed = firstFailedNode(entry);
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200))); if (failed && failed.error) {
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200)));
} }
if (entry.state === 'queued') { if (entry.state === 'queued') {
const cancelForm = el('form', { const cancelForm = el('form', {
@ -296,11 +335,12 @@ function renderQueueEntry(entry, _byId, isChild) {
} }
// ─── running-rebuild live log ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
// One persistent live-log panel for the currently-running rebuild (the queue // One persistent live-log panel for the first currently-building node
// runs one build at a time). Keyed to the running entry's build_log_id and // (build logs are per-node now; with buildSlots = 1 at most one nix
// kept in its own container (#rebuild-live-log) so the queue's row re-render // build runs at a time). Keyed to that node's build_log_id and kept in
// — which rebuilds rows as the build step advances — never tears down the open // its own container (#rebuild-live-log) so the queue's row re-render —
// SSE stream. // which rebuilds rows as the build step advances — never tears down the
// open SSE stream.
let liveLogEs = null; let liveLogEs = null;
let liveLogId = null; let liveLogId = null;
let liveLogDone = false; let liveLogDone = false;
@ -310,23 +350,36 @@ function closeLiveLogStream() {
if (liveLogEs) { liveLogEs.close(); liveLogEs = null; } 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) { function renderRebuildLiveLog(queue) {
const root = $('rebuild-live-log'); const root = $('rebuild-live-log');
if (!root) return; if (!root) return;
const running = (queue || []).find( const live = findLiveBuild(queue);
(e) => e.state === 'running' && e.build_log_id != null);
if (!running) { if (!live) {
closeLiveLogStream(); closeLiveLogStream();
liveLogId = null; liveLogId = null;
liveLogDone = false; liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); } if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
return; return;
} }
if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return; const { entry: running, node: liveNode } = live;
if (liveNode.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
closeLiveLogStream(); closeLiveLogStream();
liveLogId = running.build_log_id; liveLogId = liveNode.build_log_id;
liveLogDone = false; liveLogDone = false;
root.hidden = false; root.hidden = false;
root.replaceChildren(); root.replaceChildren();
@ -351,17 +404,18 @@ function renderRebuildLiveLog(queue) {
toggle, ' ', toggle, ' ',
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '), el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
el('code', { class: 'rqe-agent' }, running.agent), el('code', { class: 'rqe-agent' }, running.agent),
' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'), ' ', el('span', { class: 'rqe-kind' },
(running.kind || 'rebuild') + ' · ' + (NODE_KIND_LABEL[liveNode.kind] || liveNode.kind)),
' ', badge, ' ', ' ', badge, ' ',
el('a', { el('a', {
class: 'rebuild-live-log-raw', class: 'rebuild-live-log-raw',
href: '/api/build-logs/id/' + running.build_log_id + '/raw', href: '/api/build-logs/id/' + liveNode.build_log_id + '/raw',
download: 'build-log-' + running.build_log_id + '.txt', download: 'build-log-' + liveNode.build_log_id + '.txt',
}, '↓ raw'), }, '↓ raw'),
); );
root.append(header, pre); root.append(header, pre);
liveLogEs = openBuildLogStream(running.build_log_id, pre, { liveLogEs = openBuildLogStream(liveNode.build_log_id, pre, {
onDone: (status) => { onDone: (status) => {
liveLogDone = true; liveLogDone = true;
badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail'); badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail');

View file

@ -134,6 +134,33 @@
.rqe-source-approval { color: var(--green); border-color: var(--green); } .rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-when { color: var(--muted); font-size: 0.85em; } .rqe-when { color: var(--muted); font-size: 0.85em; }
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; } .rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
/* Per-node DAG chain: one chip per primitive node, dependency order. */
.rqe-nodes {
flex-basis: 100%;
margin: 0.15em 0 0 1.8em;
font-size: 0.85em;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15em;
}
.rqe-node {
padding: 0.05em 0.45em;
border: 1px solid var(--border);
border-radius: 0.7em;
color: var(--muted);
white-space: nowrap;
}
.rqe-node-running {
color: var(--purple);
border-color: var(--purple);
animation: badge-pulse 1.6s ease-in-out infinite;
}
.rqe-node-done { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, transparent); }
.rqe-node-failed { color: var(--red); border-color: var(--red); }
.rqe-node-cancelled { opacity: 0.55; text-decoration: line-through; }
.rqe-node-arrow { color: var(--muted); }
.rqe-node-log { margin-left: 0.1em; text-decoration: none; }
.rqe-step { .rqe-step {
flex-basis: 100%; flex-basis: 100%;
margin: 0.1em 0 0 1.8em; margin: 0.1em 0 0 1.8em;

View file

@ -52,6 +52,7 @@ let
agent_cpu_quota = cfg.agentCpuQuota; agent_cpu_quota = cfg.agentCpuQuota;
agent_memory_max = cfg.agentMemoryMax; agent_memory_max = cfg.agentMemoryMax;
model_prices = cfg.modelPrices; model_prices = cfg.modelPrices;
build_slots = cfg.buildSlots;
}; };
# Stylix theme integration (zero-op auto-detect). When the operator's # Stylix theme integration (zero-op auto-detect). When the operator's
@ -771,6 +772,22 @@ in
`"2G"`. `"2G"`.
''; '';
}; };
buildSlots = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
example = 2;
description = ''
Number of nix-heavy job-queue nodes (container prebuilds,
profile swaps, first-spawn creates, meta lock bumps) hive-c0re
runs concurrently. The default of 1 serializes all heavy nix
work like the pre-DAG rebuild queue did; raise it on hosts with
the cores/RAM to build several agent toplevels at once.
Per-agent correctness is independent of this count each
agent's container-affecting operations are serialized by its
lifecycle lease regardless.
'';
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {