Part 1 of the dashboard-event-stream-split epic: the server-side kinds= allow-list already exists and flow.js already uses it (hive-c0re/src/dashboard/state_snapshot.rs). tabs.js, builds.js, core.js, and logs.js were the remaining 4 subscribers still taking every wire kind unfiltered — pure subscription discipline, no new endpoint needed, per the investigation on the tracking issue. Each kinds= list is read directly off that page's own existing MUTATION_HANDLERS/SSE_HANDLERS dispatch table (tabs.js also needs sent, checked separately for the operator inbox) — a kind not in a page's table was already a silent no-op today, so this only removes wire/parse/dispatch-lookup cost for kinds a page never acted on, zero behavior change. Note for reviewers: the SharedWorker (stream-worker.js) multiplexes by exact URL string, so pages that used to share one unfiltered upstream connection when open simultaneously (e.g. dashboard.html + builds.html in two tabs) will now each hold their own filtered connection instead, since their kinds= differ. Each connection is still a single cheap SSE stream carrying only what that page acts on — net win over the shared-but-bloated connection this replaces.
815 lines
32 KiB
JavaScript
815 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 <hive-tab-strip> (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 { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { bindAsyncForms } from '@hive/shared/forms.js';
|
|
import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js';
|
|
import '@hive/shared/hive-tab-strip.js';
|
|
|
|
// ─── derived state ───────────────────────────────────────────────────────────
|
|
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.
|
|
|
|
// Node/DAG states arrive in the wire spelling of `hive_jobq::State` — the
|
|
// scheduler's own enum, serialised verbatim, so the names are PascalCase and
|
|
// there is no separate display-shaped wire type. Compare against those names;
|
|
// lowercase only where a CSS class or human-facing label needs it.
|
|
const stateSlug = (s) => String(s || '').toLowerCase();
|
|
|
|
// Rollup state from nodes (Failed > Cancelled > Running > Pending).
|
|
// `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';
|
|
// `Finishing` is a node whose own work is done while its sub-nodes still
|
|
// run — in flight, so it counts as running.
|
|
if (ns.some((n) => n.state === 'Running' || n.state === 'Finishing')) return 'Running';
|
|
// A skipped node is a branch the run ruled out, which is expected on a
|
|
// healthy DAG — it must not make the roll-up read as still-pending. This
|
|
// mirrors `DagView::rollup_state` in hive-host-sock; edit the two together.
|
|
if (ns.every((n) => n.state === 'Skipped' || n.state === 'Done')) return 'Done';
|
|
return 'Pending';
|
|
}
|
|
|
|
|
|
// 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;
|
|
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_STATE_GLYPH = {
|
|
queued: '⏸',
|
|
running: '▶',
|
|
done: '✔',
|
|
failed: '✖',
|
|
cancelled: '⊘',
|
|
// Not-taken branch of an outcome split (e.g. the failure tail on a
|
|
// successful deploy) — expected, not an error, so a quiet glyph rather
|
|
// than an attention-grabbing one. Only ever visible while the owning
|
|
// DAG is still live/failed — a fully-settled green DAG drops off the
|
|
// wire entirely (see hive-host-sock::jobs::dag_view). Same glyph
|
|
// hivectl uses for the same state (no contract between them, just
|
|
// consistent taste).
|
|
skipped: '·',
|
|
};
|
|
|
|
function firstFailedNode(entry) {
|
|
return (entry.nodes || []).find((n) => n.state === 'Failed') || null;
|
|
}
|
|
|
|
// Topo-sort a flat node list using `deps` edges. Nodes whose deps are all
|
|
// absent (Done, filtered) or within the set come first. Falls back to
|
|
// original array order on ties or cycles.
|
|
function topoSort(nodes) {
|
|
const ids = new Set(nodes.map((n) => n.id));
|
|
const indeg = new Map(nodes.map((n) => [n.id, 0]));
|
|
for (const n of nodes) {
|
|
for (const d of n.deps || []) {
|
|
if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1);
|
|
}
|
|
}
|
|
const remaining = new Map(nodes.map((n) => [n.id, n]));
|
|
const ordered = [];
|
|
const ready = nodes.filter((n) => indeg.get(n.id) === 0);
|
|
while (ready.length) {
|
|
const n = ready.shift();
|
|
if (!remaining.has(n.id)) continue;
|
|
remaining.delete(n.id);
|
|
ordered.push(n);
|
|
for (const other of nodes) {
|
|
if ((other.deps || []).includes(n.id) && remaining.has(other.id)) {
|
|
indeg.set(other.id, indeg.get(other.id) - 1);
|
|
if (indeg.get(other.id) === 0) ready.push(other);
|
|
}
|
|
}
|
|
}
|
|
for (const n of nodes) {
|
|
if (remaining.has(n.id)) ordered.push(n);
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
// Build a tree from a flat node list using the `parent` field provided by
|
|
// the backend. Nodes without a `parent` (or whose parent id is absent from
|
|
// the node set) are roots. Children within each parent group are
|
|
// topo-sorted by `deps` so siblings render in dependency order.
|
|
// Returns an array of root nodes, each augmented with a `_children` array.
|
|
function buildNodeTree(nodes) {
|
|
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
|
const roots = [];
|
|
for (const n of byId.values()) {
|
|
const p = n.parent != null ? byId.get(n.parent) : null;
|
|
if (p) {
|
|
p._children.push(n);
|
|
} else {
|
|
roots.push(n);
|
|
}
|
|
}
|
|
// Topo-sort roots and each children list by deps.
|
|
function sortGroup(group) {
|
|
const sorted = topoSort(group);
|
|
for (const n of sorted) sortGroup(n._children);
|
|
return sorted;
|
|
}
|
|
return sortGroup(roots);
|
|
}
|
|
|
|
function rebuildQueueEntryFingerprint(entry) {
|
|
const nodes = entry.nodes || [];
|
|
return JSON.stringify({
|
|
state: rollupState(nodes),
|
|
source: entry.source,
|
|
started_at: isoToSecs(entry.started_at),
|
|
created_at: entry.created_at,
|
|
finished_at: isoToSecs(entry.finished_at),
|
|
reason: entry.reason,
|
|
nodes: nodes.map((n) => [n.kind, n.state, isoToSecs(n.started_at), isoToSecs(n.finished_at), 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 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 slug = stateSlug(state);
|
|
const li = el('li', {
|
|
class: 'rebuild-queue-entry rqe-' + slug,
|
|
'data-id': String(entry.id),
|
|
});
|
|
li.append(
|
|
el('span', { class: 'rqe-state', title: slug }, QUEUE_STATE_GLYPH[slug] || '?'),
|
|
' ',
|
|
el('span', { class: 'rqe-kind' }, entry.source),
|
|
);
|
|
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
|
if (state === 'Pending') {
|
|
li.append(' ', el('span', {
|
|
class: 'rqe-when',
|
|
'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(startedAt),
|
|
}, '· ' + fmtElapsed(elapsed)));
|
|
} else if (finishedAt) {
|
|
li.append(' ', el('span', {
|
|
class: 'rqe-when',
|
|
'data-rqe-finished': String(finishedAt),
|
|
'data-rqe-state': slug,
|
|
}, '· ' + slug + ' ' + 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 tree: render the jobq recursive parent/child tree.
|
|
// `parent` edges (structural grouping) define the tree shape;
|
|
// `deps` edges order siblings within each parent group.
|
|
// `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 treeRoot = el('div', { class: 'rqe-nodes-tree' });
|
|
const treeNodes = buildNodeTree(nodes);
|
|
function renderTreeNode(n, depth, isLast, ancestorLines) {
|
|
// ancestorLines: boolean[] where true = draw a vertical guide line at
|
|
// that ancestor depth level (the ancestor was not the last sibling, so
|
|
// its remaining siblings need a guide column below it).
|
|
const row = el('div', { class: 'rqe-tree-row' });
|
|
if (depth > 0) {
|
|
// One guide column per ancestor level — draws a vertical line through
|
|
// columns where the ancestor still has siblings below it.
|
|
for (const hasLine of ancestorLines) {
|
|
row.append(el('span', {
|
|
class: 'rqe-tree-guide' + (hasLine ? ' rqe-tree-guide-line' : ''),
|
|
}));
|
|
}
|
|
// Connector: L-shaped for last child, T-shaped for mid child.
|
|
row.append(el('span', {
|
|
class: 'rqe-tree-connector'
|
|
+ (isLast ? ' rqe-tree-connector-last' : ' rqe-tree-connector-mid'),
|
|
}));
|
|
}
|
|
const chip = el('span', {
|
|
class: 'rqe-node rqe-node-' + stateSlug(n.state),
|
|
title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state
|
|
+ (n.error ? ' — ' + n.error : ''),
|
|
}, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
|
|
if (n.agent) {
|
|
chip.append(el('span', { class: 'rqe-node-agent' }, ' · ' + n.agent));
|
|
}
|
|
row.append(chip);
|
|
if (n.build_log_id != null) {
|
|
// Deep-links into the BUILD L0GS tab's rich view (auto-expands +
|
|
// scrolls to this row there, see fetchBuild's `?id=N` handling)
|
|
// rather than the raw-text download endpoint — a plain download
|
|
// is surprising here since nothing about a printer-glyph icon
|
|
// says "this leaves the app". The raw download is still one
|
|
// click away once on that row.
|
|
row.append(el('a', {
|
|
class: 'rqe-log-link rqe-node-log',
|
|
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
|
|
title: 'view build log for ' + n.kind + ' node',
|
|
}, '⎙'));
|
|
}
|
|
treeRoot.append(row);
|
|
// Propagate ancestor lines to children: inherit this node's columns,
|
|
// plus whether this node itself continues below (not the last sibling).
|
|
const childAncestorLines = depth === 0 ? [] : [...ancestorLines, !isLast];
|
|
n._children.forEach((child, i) => {
|
|
renderTreeNode(child, depth + 1, i === n._children.length - 1, childAncestorLines);
|
|
});
|
|
}
|
|
treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, []));
|
|
li.append(treeRoot);
|
|
}
|
|
const failed = firstFailedNode(entry);
|
|
if (failed && failed.error) {
|
|
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200)));
|
|
}
|
|
if (state === 'Pending') {
|
|
const cancelForm = el('form', {
|
|
method: 'POST',
|
|
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
|
class: 'inline rqe-cancel',
|
|
'data-async': '',
|
|
'data-confirm':
|
|
`cancel ${entry.source} (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.source,
|
|
'aria-label': 'cancel queued ' + entry.source,
|
|
}, '✗'));
|
|
li.append(cancelForm);
|
|
}
|
|
return li;
|
|
}
|
|
|
|
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
|
// 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 clearLiveLogPoll() {
|
|
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
|
|
}
|
|
|
|
// First (entry, node) pair with a running node that has a log.
|
|
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank panel.
|
|
function findLiveBuild(queue) {
|
|
for (const e of queue || []) {
|
|
if (rollupState(e.nodes || []) !== 'Running') continue;
|
|
const node = (e.nodes || []).find((n) => n.state === 'Running' && n.build_log_id != null);
|
|
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) {
|
|
clearLiveLogPoll();
|
|
liveLogId = null;
|
|
liveLogDone = false;
|
|
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
|
return;
|
|
}
|
|
const { entry: running, node: liveNode } = live;
|
|
|
|
// 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();
|
|
|
|
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 — '),
|
|
// 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.source + ' · ' + liveNode.kind),
|
|
' ', badge, ' ',
|
|
el('a', {
|
|
class: 'rebuild-live-log-raw',
|
|
href: '/api/build-log/' + liveNode.id + '/raw',
|
|
download: 'build-log-' + liveNode.id + '.txt',
|
|
}, '↓ raw'),
|
|
);
|
|
root.append(header, pre);
|
|
|
|
// Start polling.
|
|
fetchAndRenderLiveLog(liveNode.id, pre);
|
|
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000);
|
|
}
|
|
|
|
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
|
|
function updateRebuildCount() {
|
|
const pill = $('builds-tab-count-rebuild');
|
|
if (!pill) return;
|
|
let n = 0;
|
|
for (const e of rebuildQueueState) {
|
|
const s = rollupState(e.nodes || []);
|
|
if (s === 'Pending' || s === '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 = document.getElementById('builds-tabbar').configure({
|
|
tabs: [
|
|
{ id: 'rebuild', label: 'R3BU1LD QU3U3', badgeId: 'builds-tab-count-rebuild' },
|
|
{ id: 'meta', label: 'M3T4 1NPUTS' },
|
|
{ id: 'buildlogs', label: 'BUILD L0GS' },
|
|
],
|
|
defaultId: 'rebuild',
|
|
onShow: (id) => {
|
|
if (id === 'buildlogs' && !buildLogsLoaded) {
|
|
buildLogsLoaded = true;
|
|
fetchBuild();
|
|
}
|
|
},
|
|
});
|
|
|
|
await refreshState();
|
|
|
|
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
|
|
// 17 wire kinds down to the 3 this page acts on. This page was one
|
|
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
|
|
// (subscription discipline, part 1 of the dashboard-event-stream-
|
|
// split issue).
|
|
const es = openStream(
|
|
'/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running',
|
|
);
|
|
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();
|