dashboard: stream the running rebuild's build log inline in the queue

The C0R3 rebuild queue only linked out to the logs page (logs →). Add an
inline live-log panel under the queue that streams the currently-running
rebuild's build output, so the operator watches progress without leaving the
page.

One panel keyed to the running entry's build_log_id (the queue runs one build
at a time), reusing the build-log SSE the logs page already uses
(GET /api/build-logs/id/{id}/stream; frames stdout_append/stderr_append/done).
It lives in its own container (#rebuild-live-log) outside rebuild-queue-section
so the queue's per-row re-render — rows rebuild as the build step advances —
never tears down the open stream; it reconnects only when the running
build_log_id changes and won't reopen a stream that already sent done. Sticky-
bottom scroll, collapsible, live/ok/fail badge, raw download. Hidden when
nothing is building; each row keeps its logs → link for full history.

Frontend-only — no backend change (endpoint + build_log_id already existed).
Closes #1860.
This commit is contained in:
iris 2026-06-22 01:48:32 +02:00
commit 93fa264bbf
4 changed files with 200 additions and 1 deletions

View file

@ -158,9 +158,10 @@ function rebuildQueueEntryFingerprint(entry, isChild) {
}
function renderRebuildQueue(s) {
const queue = s.rebuild_queue || [];
renderRebuildLiveLog(queue);
const root = $('rebuild-queue-section');
if (!root) return;
const queue = s.rebuild_queue || [];
if (!queue.length) {
rebuildQueueRowCache.clear();
@ -294,6 +295,121 @@ function renderQueueEntry(entry, _byId, isChild) {
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);
// Sticky-bottom: auto-scroll to bottom on new lines unless the operator
// scrolled up (track intent via the scroll event — same as the logs page).
let atBottom = true;
pre.addEventListener('scroll', () => {
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
});
let stderrSeen = false;
const es = new EventSource('/api/build-logs/id/' + running.build_log_id + '/stream');
liveLogEs = es;
es.onmessage = (ev) => {
let frame;
try { frame = JSON.parse(ev.data); } catch { return; }
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.stderr_append) {
if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; }
pre.textContent += frame.stderr_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.done) {
liveLogDone = true;
badge.className = 'rebuild-live-log-badge '
+ (frame.status === 'ok' ? 'rll-ok' : 'rll-fail');
badge.textContent = frame.status || 'done';
if (es === liveLogEs) { es.close(); liveLogEs = null; }
// Keep liveLogId + liveLogDone so a re-render before the entry leaves
// 'running' doesn't reopen the finished stream; the next render with no
// running entry clears the panel.
}
};
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) return; // transient reconnect
badge.className = 'rebuild-live-log-badge rll-fail';
badge.textContent = 'stream error';
if (es === liveLogEs) { es.close(); liveLogEs = null; }
};
}
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $('tombstones-section');