Per mara's steer on #3053 ("chip/pill/badge is the same if you squint ... theme unification is part of the goal" then "make it common css instead of component, thats fine. but make them look unified (not as much per usage css)"): shared CSS, not a JS component. New @hive/shared/pill.css defines two classes, `.hive-pill` (primary state chips) and `.hive-pill-sm` (secondary meta chips) — border/ border-radius/padding/font-size/letter-spacing (colour stays per-site, the meaningful semantic part). Every render call site across dashboard (swarm.js/core.js/builds.js) and agent (index.html/app.js) now carries one of the two shared classes directly, alongside its own existing semantic-colour modifier class. Second cut of this PR, per argus's approve + mara's follow-up review comment on the first cut: the first version instead enumerated every legacy classname (`.badge`, `.status-badge`, `.header-pill`, etc.) straight into pill.css's own selector groups so no call sites needed touching. Mara's correction: that just relocates the duplication rather than removing it, and the shared CSS shouldn't have to keep naming every consumer. This version does the real rename instead. Most visible consequence, unchanged from the first cut: dashboard's `.badge` family moves off its own shape (2px square corners, uppercase, tighter padding) onto the shared rounded-pill shape + agent's "sm" tier sizing. `npm run build` clean across all three packages; verified the compiled bundles carry the new classnames at every call site (dashboard JS, agent index.html + app.js), not just the source tree. Fixes #3053
543 lines
22 KiB
JavaScript
543 lines
22 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 { themedConfirm } from '@hive/shared/modal.js';
|
|
import { fmtAgo, fmtDuration, truncate } from './util.js';
|
|
import '@hive/shared/hive-tab-strip.js';
|
|
import '@hive/shared/jobq-graph.js';
|
|
|
|
// ─── derived state ───────────────────────────────────────────────────────────
|
|
let metaInputsState = [];
|
|
let metaUpdateRunning = false;
|
|
// Kept in sync from <hive-jobq-graph>'s `hive-jobq-graph-update` event
|
|
// (see mountJobqGraph below) — the component owns fetching GET
|
|
// /api/jobq/graph and renders the tree itself; this flat array exists only
|
|
// for the two things it doesn't render: the count-pill and the live-log
|
|
// panel.
|
|
let jobqNodes = [];
|
|
let jobqGraphEl = null;
|
|
|
|
function syncFromSnapshot(s) {
|
|
metaInputsState = (s.meta_inputs || []).slice();
|
|
metaUpdateRunning = !!s.meta_update_running;
|
|
}
|
|
|
|
// ─── 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 ────────────────────────────────────────────────────────────
|
|
// R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build
|
|
// queue tab with this component") — no hand-rolled tree/roll-up rendering
|
|
// here anymore. The component owns fetching GET /api/jobq/graph and its
|
|
// own refresh(); this page just listens for its `hive-jobq-graph-update`
|
|
// event to keep `jobqNodes` (the flat array) in sync for the two things
|
|
// the generic view doesn't render itself: the count-pill and the live-log
|
|
// panel below.
|
|
//
|
|
// Cancel is the one action this page *does* wire up: the
|
|
// `cancellable` attribute turns on the component's own per-node cancel
|
|
// button, which dispatches `hive-jobq-graph-cancel` rather than posting
|
|
// anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this
|
|
// page's domain concept, not the generic component's.
|
|
function mountJobqGraph() {
|
|
const root = $('rebuild-queue-section');
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' });
|
|
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
|
|
jobqNodes = e.detail.nodes || [];
|
|
renderRebuildLiveLog();
|
|
updateRebuildCount();
|
|
});
|
|
jobqGraphEl.addEventListener('hive-jobq-graph-cancel', async (e) => {
|
|
const { id } = e.detail;
|
|
const node = jobqNodes.find((n) => n.id === id);
|
|
const label = node ? node.payload.label : 'node ' + id;
|
|
if (!(await themedConfirm({
|
|
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
|
danger: true, confirmLabel: '✕ cancel',
|
|
}))) return;
|
|
try {
|
|
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
|
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
// No manual refresh: cancel flips node state, which fires
|
|
// rebuild_queue_changed over SSE — the existing handler below
|
|
// already calls jobqGraphEl.refresh() on that tick.
|
|
} catch (err) {
|
|
console.error('cancel failed', err);
|
|
}
|
|
});
|
|
root.append(jobqGraphEl);
|
|
}
|
|
|
|
// ─── 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 running node with a log, in wire order (root-then-subtree per
|
|
// root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc).
|
|
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
|
|
// panel.
|
|
function findLiveBuild() {
|
|
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
|
|
|| 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() {
|
|
const root = $('rebuild-live-log');
|
|
if (!root) return;
|
|
const liveNode = findLiveBuild();
|
|
|
|
if (!liveNode) {
|
|
clearLiveLogPoll();
|
|
liveLogId = null;
|
|
liveLogDone = false;
|
|
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
|
return;
|
|
}
|
|
|
|
// 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 liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
|
|
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 group's agent set.
|
|
el('code', { class: 'rqe-agent' }, liveAgent || ''),
|
|
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
|
|
' ', 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;
|
|
// Pending/Running/Finishing = in flight (Finishing = own work done, a
|
|
// sub-node still running — still counts). Root nodes only: each is one
|
|
// queue entry.
|
|
const n = jobqNodes.filter((n) => n.parent == null
|
|
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length;
|
|
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
|
else { pill.hidden = true; }
|
|
}
|
|
|
|
// ─── render-all (cold load + any full re-render) ──────────────────────────────
|
|
function renderAll() {
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
|
|
// off <hive-jobq-graph>'s own hive-jobq-graph-update event (see
|
|
// mountJobqGraph) — nothing to render here directly.
|
|
}
|
|
|
|
// ─── 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 ? 'hive-pill-sm badge-running' : ok ? 'hive-pill-sm badge-ok' : 'hive-pill-sm 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 hive-pill-sm 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' ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
|
|
badge.textContent = status;
|
|
streamEs = null;
|
|
detail.dataset.loaded = '1';
|
|
},
|
|
onError: () => {
|
|
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
|
badge.textContent = 'stream error';
|
|
badge.className = 'hive-pill-sm 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() {
|
|
// No `ev.queue` payload read anymore — this event still carries its own
|
|
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the
|
|
// swarm badges, untouched by this), this page just treats it as a
|
|
// refetch trigger. <hive-jobq-graph> owns the actual fetch.
|
|
if (jobqGraphEl) jobqGraphEl.refresh();
|
|
// 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());
|
|
// Self-fetches on mount (<hive-jobq-graph>'s own connectedCallback) —
|
|
// no explicit initial fetch needed here, unlike meta inputs below.
|
|
mountJobqGraph();
|
|
|
|
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();
|