// 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 (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) 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/ (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();