From 93fa264bbf8f7c7231309ffd5170fd59f59ea8ab Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 22 Jun 2026 01:48:32 +0200 Subject: [PATCH] dashboard: stream the running rebuild's build log inline in the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/web-ui/dashboard.md | 11 ++ frontend/packages/dashboard/src/core.css | 67 ++++++++++++ frontend/packages/dashboard/src/core.html | 5 + frontend/packages/dashboard/src/core.js | 118 +++++++++++++++++++++- 4 files changed, 200 insertions(+), 1 deletion(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 0328ed54..08f8e686 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -179,6 +179,17 @@ cancel flips the row to `⊘ cancelled` via the next Cold-loaded from `/api/state.rebuild_queue`; live updates via `rebuild_queue_changed` snapshot event. +Below the queue, a **live build-log panel** (`#rebuild-live-log`, +`renderRebuildLiveLog`) streams the currently-running rebuild's output +inline — collapsible, with a live/ok/fail badge and a `↓ raw` download. +It's keyed to the running entry's `build_log_id` and opens one +`EventSource` to `GET /api/build-logs/id/{id}/stream` (the same stream +the L0GS page BUILD tab uses; the stream replays accumulated output on +connect). It lives in its own container outside `#rebuild-queue-section` +so the queue's per-row re-render (rows rebuild as the `step` advances) +never tears down the open stream; it hides when nothing is building and +each row keeps its `logs →` link out to the full L0GS history. + **K3PT ST4T3** — destroyed-but-state-kept tombstones (size + age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a Spawn approval; existing state is reused), `PURG3` (wipes diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css index 46755bdb..57509df9 100644 --- a/frontend/packages/dashboard/src/core.css +++ b/frontend/packages/dashboard/src/core.css @@ -33,3 +33,70 @@ body.core-shell { /* createTabStrip toggles the native `hidden` attribute on inactive panes; ensure it wins over any inherited display. */ .core-pane[hidden] { display: none; } + +/* ─── running-rebuild live log (renderRebuildLiveLog in core.js) ────────── + Streams the currently-running rebuild's build log inline under the queue. + Hidden (native `hidden` attr) when nothing is building. */ +.rebuild-live-log { + margin-top: 0.8em; + border: 1px solid var(--purple-dim); + border-radius: 4px; + background: color-mix(in srgb, var(--crust) 70%, transparent); +} +.rebuild-live-log-header { + display: flex; + align-items: center; + gap: 0.4em; + padding: 0.4em 0.6em; + font-size: 0.85em; + border-bottom: 1px solid var(--border); +} +.rebuild-live-log-toggle { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font-family: inherit; + font-size: 1em; + padding: 0 0.2em; +} +.rebuild-live-log-toggle:hover { color: var(--fg); } +.rebuild-live-log-title { color: var(--muted); } +.rebuild-live-log-raw { + margin-left: auto; + color: var(--purple); + text-decoration: none; + font-size: 0.9em; +} +.rebuild-live-log-raw:hover { text-decoration: underline; } +.rebuild-live-log-badge { + font-size: 0.75em; + font-weight: bold; + padding: 0.05em 0.5em; + border-radius: 999px; + letter-spacing: 0.05em; +} +.rebuild-live-log-badge.rll-running { + color: var(--amber); + background: color-mix(in srgb, var(--amber) 18%, transparent); +} +.rebuild-live-log-badge.rll-ok { + color: var(--green); + background: color-mix(in srgb, var(--green) 18%, transparent); +} +.rebuild-live-log-badge.rll-fail { + color: var(--red); + background: color-mix(in srgb, var(--red) 18%, transparent); +} +.rebuild-live-log-output { + margin: 0; + padding: 0.5em 0.7em; + max-height: 22em; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + font-size: 0.8em; + line-height: 1.4; + color: var(--fg); +} diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 51c99803..6de67577 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -55,6 +55,11 @@

loading…

+ + diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index d613a2e8..f84022ab 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -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');