feat(builds): add /builds.html — build lifecycle hub (closes #1999)
New standalone page /builds.html consolidating rebuild queue, meta
inputs, and build log history into one place, with a new 'Builds' home
tile linking to it. Addresses mara's request: new sub-page with a new
home tile, all three items moved there.
Changes:
- builds.html: new page with three sub-tabs: R3BU1LD QU3U3, M3T4 1NPUTS,
BUILD L0GS. Same minimal-chrome header + createTabStrip pattern as core
and logs pages.
- builds.js: new bundle combining rebuild queue renderer (from core.js),
meta inputs renderer (from core.js), rebuild-live-log renderer (from
core.js), and build log history renderer (from logs.js). Deep-links to
/builds.html?id=N#buildlogs. Count pill id: builds-tab-count-rebuild.
BUILD L0GS tab lazy-loads on first activation.
- builds.css: @imports system-sections.css (rebuild queue + meta inputs +
live-log styles) and logs.css (build-logs-* component styles).
- build.mjs: register builds.js, builds.css, builds.html.
- core.html: remove R3BU1LD QU3U3 + M3T4 1NPUTS tabs (now on builds.html).
Default tab changes to K3PT ST4T3.
- core.js: remove renderMetaInputs, renderRebuildQueue + helpers,
renderRebuildLiveLog + live-log state, elapsed-time tickers,
updateRebuildCount, and the rebuild_queue/meta_inputs SSE handlers.
Remove openBuildLogStream + util imports no longer needed.
- core.css: remove .rebuild-live-log-* rules (moved to system-sections.css
so builds.css can share them via @import).
- system-sections.css: add .rebuild-live-log-* styles (moved from core.css);
update comment to mention builds.html.
- logs.html: remove BUILD tab + pane (moved to builds.html).
- logs.js: remove fetchBuild(), fmtTs, fmtDuration, openBuildLogStream
import, and rebuild_queue_changed SSE debounce. Default tab: 'agent'.
SSE stream retained for audit_entry_added live-appends.
- index.html: add Builds tile (🔨, rebuild queue · meta inputs · build
logs); update Core tile desc to 'kept state · container load'; update
Logs tile desc to remove 'build'.
This commit is contained in:
parent
75e49f7752
commit
beb28d5c37
11 changed files with 896 additions and 790 deletions
|
|
@ -1,391 +1,22 @@
|
|||
// C0R3 page entry (/core.html). The host/coordinator surface carved out
|
||||
// of the dashboard's old SYST3M tab into its own standalone page so the
|
||||
// dashboard tab strip stays lean. Four sub-tabs via the shared
|
||||
// createTabStrip (default = Rebuild Queue): rebuild queue, meta inputs,
|
||||
// kept state (tombstones), container load.
|
||||
// dashboard tab strip stays lean. Two sub-tabs via the shared
|
||||
// createTabStrip (default = K3PT ST4T3): kept state (tombstones) and
|
||||
// container load.
|
||||
//
|
||||
// This page is its own esbuild bundle with its own runtime: it cold-loads
|
||||
// /api/state and subscribes to /dashboard/stream (the same broker event
|
||||
// channel the dashboard uses), maintaining its own copies of the derived
|
||||
// state. The four section renderers below are ported from tabs.js (the
|
||||
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
||||
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
||||
// Rebuild queue + meta inputs have moved to /builds.html (the build
|
||||
// lifecycle hub). This page is its own esbuild bundle with its own
|
||||
// runtime: it cold-loads /api/state and subscribes to /dashboard/stream
|
||||
// (the same broker event channel the dashboard uses), maintaining its
|
||||
// own copy of the tombstones state.
|
||||
|
||||
import { $, el, form, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
|
||||
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
||||
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
|
||||
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
||||
let metaInputsState = [];
|
||||
let metaUpdateRunning = false;
|
||||
let tombstonesState = [];
|
||||
let rebuildQueueState = [];
|
||||
function syncFromSnapshot(s) {
|
||||
metaInputsState = (s.meta_inputs || []).slice();
|
||||
metaUpdateRunning = !!s.meta_update_running;
|
||||
tombstonesState = (s.tombstones || []).slice();
|
||||
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_KIND_GLYPH = {
|
||||
rebuild: '↻',
|
||||
meta_update: '◆',
|
||||
spawn: '✨',
|
||||
destroy: '🗑',
|
||||
restart: '↺',
|
||||
startup_sweep: '⚡',
|
||||
perm_change: '🔑',
|
||||
};
|
||||
const QUEUE_STATE_GLYPH = {
|
||||
queued: '⏸',
|
||||
running: '▶',
|
||||
done: '✔',
|
||||
failed: '✖',
|
||||
cancelled: '⊘',
|
||||
};
|
||||
|
||||
function rebuildQueueEntryFingerprint(entry, isChild) {
|
||||
return JSON.stringify({
|
||||
state: entry.state,
|
||||
step: entry.step,
|
||||
kind: entry.kind,
|
||||
agent: entry.agent,
|
||||
source: entry.source,
|
||||
started_at: entry.started_at,
|
||||
enqueued_at: entry.enqueued_at,
|
||||
finished_at: entry.finished_at,
|
||||
reason: entry.reason,
|
||||
error: entry.error,
|
||||
build_log_id: entry.build_log_id,
|
||||
isChild,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const byId = new Map(queue.map((e) => [e.id, e]));
|
||||
const tops = queue.filter((e) => e.parent_id == null);
|
||||
const childrenOf = new Map();
|
||||
for (const e of queue) {
|
||||
if (e.parent_id != null) {
|
||||
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
|
||||
childrenOf.get(e.parent_id).push(e);
|
||||
}
|
||||
}
|
||||
const orphans = queue.filter((e) => e.parent_id != null && !byId.has(e.parent_id));
|
||||
|
||||
const orderedLis = [];
|
||||
function addEntry(entry, isChild) {
|
||||
const fp = rebuildQueueEntryFingerprint(entry, isChild);
|
||||
const cached = rebuildQueueRowCache.get(entry.id);
|
||||
let li;
|
||||
if (cached && cached.fingerprint === fp) {
|
||||
li = cached.el;
|
||||
} else {
|
||||
li = renderQueueEntry(entry, byId, isChild);
|
||||
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
|
||||
}
|
||||
orderedLis.push(li);
|
||||
}
|
||||
for (const top of tops) {
|
||||
addEntry(top, false);
|
||||
for (const child of childrenOf.get(top.id) || []) addEntry(child, true);
|
||||
}
|
||||
for (const o of orphans) addEntry(o, true);
|
||||
|
||||
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, _byId, isChild) {
|
||||
const li = el('li', {
|
||||
class: 'rebuild-queue-entry rqe-' + entry.state,
|
||||
'data-id': String(entry.id),
|
||||
});
|
||||
if (isChild) li.classList.add('rqe-child');
|
||||
li.append(
|
||||
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
|
||||
' ',
|
||||
el('span', { class: 'rqe-kind', title: entry.kind },
|
||||
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
|
||||
' ',
|
||||
el('code', { class: 'rqe-agent' }, entry.agent),
|
||||
);
|
||||
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
||||
if (entry.state === 'queued') {
|
||||
li.append(' ', el('span', {
|
||||
class: 'rqe-when',
|
||||
'data-rqe-enqueued': String(entry.enqueued_at),
|
||||
}, '· queued ' + fmtAgo(entry.enqueued_at)));
|
||||
} else if (entry.state === 'running' && entry.started_at) {
|
||||
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
|
||||
li.append(' ', el('span', {
|
||||
class: 'rqe-when',
|
||||
'data-rqe-elapsed': String(entry.started_at),
|
||||
}, '· ' + fmtElapsed(elapsed)));
|
||||
} else if (entry.finished_at) {
|
||||
li.append(' ', el('span', {
|
||||
class: 'rqe-when',
|
||||
'data-rqe-finished': String(entry.finished_at),
|
||||
'data-rqe-state': entry.state,
|
||||
}, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
|
||||
}
|
||||
if (entry.reason) {
|
||||
const r = entry.reason.split('\n')[0];
|
||||
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
||||
}
|
||||
if (entry.step) {
|
||||
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
|
||||
}
|
||||
if (entry.build_log_id != null) {
|
||||
li.append(
|
||||
' ',
|
||||
el('a', {
|
||||
class: 'rqe-log-link',
|
||||
href: '/logs.html?id=' + entry.build_log_id + '#build',
|
||||
target: '_blank',
|
||||
title: 'view build log #' + entry.build_log_id,
|
||||
}, 'logs →'),
|
||||
);
|
||||
}
|
||||
if (entry.error) {
|
||||
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
||||
}
|
||||
if (entry.state === 'queued') {
|
||||
const cancelForm = el('form', {
|
||||
method: 'POST',
|
||||
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
||||
class: 'inline rqe-cancel',
|
||||
'data-async': '',
|
||||
'data-confirm':
|
||||
`cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` +
|
||||
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
|
||||
});
|
||||
cancelForm.append(el('button', {
|
||||
type: 'submit',
|
||||
class: 'rqe-cancel-btn',
|
||||
title: 'cancel this queued ' + entry.kind,
|
||||
'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent,
|
||||
}, '✗'));
|
||||
li.append(cancelForm);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
// ─── running-rebuild live log ─────────────────────────────────────────────
|
||||
// One persistent live-log panel for the currently-running rebuild (the queue
|
||||
// runs one build at a time). Keyed to the running entry's build_log_id and
|
||||
// kept in its own container (#rebuild-live-log) so the queue's row re-render
|
||||
// — which rebuilds rows as the build step advances — never tears down the open
|
||||
// SSE stream. Reuses the build-log stream the logs page BUILD tab uses
|
||||
// (GET /api/build-logs/id/{id}/stream; frames {stdout_append, stderr_append,
|
||||
// done, status}); the stream replays accumulated output on connect, so
|
||||
// attaching mid-build still shows the backlog.
|
||||
let liveLogEs = null;
|
||||
let liveLogId = null;
|
||||
let liveLogDone = false;
|
||||
let liveLogCollapsed = false;
|
||||
|
||||
function closeLiveLogStream() {
|
||||
if (liveLogEs) { liveLogEs.close(); liveLogEs = null; }
|
||||
}
|
||||
|
||||
function renderRebuildLiveLog(queue) {
|
||||
const root = $('rebuild-live-log');
|
||||
if (!root) return;
|
||||
const running = (queue || []).find(
|
||||
(e) => e.state === 'running' && e.build_log_id != null);
|
||||
|
||||
if (!running) {
|
||||
// No running build with a log → tear down + hide.
|
||||
closeLiveLogStream();
|
||||
liveLogId = null;
|
||||
liveLogDone = false;
|
||||
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
||||
return;
|
||||
}
|
||||
// Same build still streaming (or already finished) → leave the panel as-is.
|
||||
// Re-renders fire on every queue mutation; don't reconnect mid-build, and
|
||||
// don't reopen a stream that already sent `done`.
|
||||
if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
|
||||
|
||||
// New running build → (re)build the panel + open its stream.
|
||||
closeLiveLogStream();
|
||||
liveLogId = running.build_log_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 — '),
|
||||
el('code', { class: 'rqe-agent' }, running.agent),
|
||||
' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'),
|
||||
' ', badge, ' ',
|
||||
el('a', {
|
||||
class: 'rebuild-live-log-raw',
|
||||
href: '/api/build-logs/id/' + running.build_log_id + '/raw',
|
||||
download: 'build-log-' + running.build_log_id + '.txt',
|
||||
}, '↓ raw'),
|
||||
);
|
||||
root.append(header, pre);
|
||||
|
||||
// Stream via the shared helper (same path the L0GS BUILD tab uses). It owns
|
||||
// the append / sticky-scroll / stderr-separator / reconnect-replay logic; we
|
||||
// just wire the badge. onDone keeps liveLogId + liveLogDone set so a
|
||||
// re-render before the entry leaves 'running' won't reopen the finished
|
||||
// stream — the next render with no running entry clears the panel.
|
||||
liveLogEs = openBuildLogStream(running.build_log_id, pre, {
|
||||
onDone: (status) => {
|
||||
liveLogDone = true;
|
||||
badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail');
|
||||
badge.textContent = status;
|
||||
liveLogEs = null;
|
||||
},
|
||||
onError: () => {
|
||||
badge.className = 'rebuild-live-log-badge rll-fail';
|
||||
badge.textContent = 'stream error';
|
||||
liveLogEs = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
||||
|
|
@ -611,64 +242,13 @@ function stopContainerLoadPolling() {
|
|||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||
}
|
||||
|
||||
// ─── rebuild-queue count pill ─────────────────────────────────────────────
|
||||
function updateRebuildCount() {
|
||||
const pill = $('core-tab-count-rebuild');
|
||||
if (!pill) return;
|
||||
let n = 0;
|
||||
for (const e of rebuildQueueState) {
|
||||
if (e.state === 'queued' || e.state === '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 });
|
||||
renderTombstones({ tombstones: tombstonesState });
|
||||
updateRebuildCount();
|
||||
}
|
||||
|
||||
// ─── elapsed-time tickers (same cadence as the dashboard) ─────────────────
|
||||
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);
|
||||
|
||||
// ─── live SSE handlers (own copies; no SW4RM re-render on this page) ───────
|
||||
// ─── live SSE handlers ───────────────────────────────────────────────────
|
||||
const SSE_HANDLERS = {
|
||||
rebuild_queue_changed(ev) {
|
||||
rebuildQueueState = (ev.queue || []).slice();
|
||||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||||
updateRebuildCount();
|
||||
},
|
||||
meta_inputs_changed(ev) {
|
||||
metaInputsState = (ev.inputs || []).slice();
|
||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||
},
|
||||
meta_update_running(ev) {
|
||||
metaUpdateRunning = !!ev.running;
|
||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||
},
|
||||
tombstones_changed(ev) {
|
||||
tombstonesState = (ev.tombstones || []).slice();
|
||||
renderTombstones({ tombstones: tombstonesState });
|
||||
|
|
@ -706,11 +286,11 @@ async function init() {
|
|||
// to the bare `ok` response page.
|
||||
bindAsyncForms(() => refreshState());
|
||||
|
||||
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
|
||||
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
|
||||
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
||||
// two-sample read each refresh on the server).
|
||||
createTabStrip(document.getElementById('core-tabbar'), {
|
||||
defaultId: 'rebuild',
|
||||
defaultId: 'kept',
|
||||
onShow: (id) => {
|
||||
if (id === 'load') startContainerLoadPolling();
|
||||
else stopContainerLoadPolling();
|
||||
|
|
|
|||
Loading…
Reference in a new issue