From e797b75ca9e6728eb2f8fd982fa02cc1f89844f4 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 22 Jun 2026 01:55:38 +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. Extract the build-log SSE streaming logic (append stdout/stderr, sticky-bottom scroll, stderr separator, reconnect-replay reset, done/error handling) into a shared `openBuildLogStream(id, pre, {onDone, onError})` in common.js, and use it from BOTH the L0GS page BUILD tab (logs.js, previously inline) and the new C0R3 panel (core.js) — one implementation, no duplication. The panel is one persistent instance keyed to the running entry's build_log_id (the queue runs one build at a time), 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. 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. --- frontend/packages/dashboard/src/common.js | 46 ++++++++++++++++++ frontend/packages/dashboard/src/core.js | 57 +++++++---------------- frontend/packages/dashboard/src/logs.js | 54 ++++++--------------- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 370517e5..2a66ffdf 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -305,6 +305,52 @@ export function openStream(url) { return target; } +// Stream a build log into a
, returning the EventSource. Shared by the
+// L0GS page BUILD tab and the C0R3 rebuild-queue live-log panel so the
+// append / sticky-scroll / stderr-separator / reconnect-replay logic lives in
+// one place. Appends `stdout_append` then `stderr_append` (one `--- stderr ---`
+// separator) frames from `GET /api/build-logs/id/{id}/stream`; auto-scrolls to
+// the bottom unless the operator scrolled up; on the terminal `done` frame
+// closes the stream and calls `onDone(status)`; on a non-transient error
+// closes and calls `onError()`. The backend replays accumulated output on each
+// (re)connect, so a CONNECTING reconnect resets the 
 to avoid doubling.
+export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
+  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/' + id + '/stream');
+  es.onmessage = (e) => {
+    let frame;
+    try { frame = JSON.parse(e.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) {
+      es.close();
+      if (onDone) onDone(frame.status || 'done');
+    }
+  };
+  es.onerror = () => {
+    // CONNECTING = the browser is auto-reconnecting; the stream replays from
+    // the start, so clear the 
 to avoid duplicated output and wait.
+    if (es.readyState === EventSource.CONNECTING) {
+      pre.textContent = ''; stderrSeen = false;
+      return;
+    }
+    es.close();
+    if (onError) onError();
+  };
+  return es;
+}
+
 // ─── side panel ─────────────────────────────────────────────────────────
 // Singleton drawer that swipes in from the right. Long content
 // (file previews, approval diffs, journald logs, applied config)
diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js
index f84022ab..e58ed487 100644
--- a/frontend/packages/dashboard/src/core.js
+++ b/frontend/packages/dashboard/src/core.js
@@ -11,7 +11,7 @@
 // dashboard SYST3M tab); the dashboard keeps its own copies for now —
 // de-duplication + removing the SYST3M tab is a deliberate follow-up.
 
-import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
+import { $, el, form, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
 import { fmtAgo, fmtElapsed, truncate } from './util.js';
 import { createTabStrip } from '@hive/shared/tabs.js';
 
@@ -369,45 +369,24 @@ function renderRebuildLiveLog(queue) {
   );
   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) {
+  // 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 '
-        + (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; }
-  };
+      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) ──────────────────────────────────────────────
diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js
index 7d82be4a..924dd6d5 100644
--- a/frontend/packages/dashboard/src/logs.js
+++ b/frontend/packages/dashboard/src/logs.js
@@ -23,7 +23,7 @@
 // SYSTEM tabs so the operator knows how stale the output is.
 
 import {
-  $, el, fmtAgeSecs, openStream, initServerWarnings,
+  $, el, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings,
 } from './common.js';
 import { createTabStrip } from '@hive/shared/tabs.js';
 
@@ -138,53 +138,29 @@ import { createTabStrip } from '@hive/shared/tabs.js';
             const badge = el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live');
             detail.append(badge, pre);
 
-            // Sticky-bottom: auto-scroll to the bottom as new lines arrive
-            // unless the operator has manually scrolled up. Track intent via
-            // the scroll event rather than recomputing on each append.
-            let atBottom = true;
-            pre.addEventListener('scroll', () => {
-              atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
-            });
-
-            streamEs = new EventSource('/api/build-logs/id/' + h.id + '/stream');
-            let stdoutLen = 0, stderrLen = 0;
-            streamEs.onmessage = (e) => {
-              let frame;
-              try { frame = JSON.parse(e.data); } catch { return; }
-              if (frame.stdout_append) {
-                pre.textContent += frame.stdout_append;
-                if (atBottom) pre.scrollTop = pre.scrollHeight;
-                stdoutLen += frame.stdout_append.length;
-              }
-              if (frame.stderr_append) {
-                if (stderrLen === 0) pre.textContent += '\n--- stderr ---\n';
-                pre.textContent += frame.stderr_append;
-                if (atBottom) pre.scrollTop = pre.scrollHeight;
-                stderrLen += frame.stderr_append.length;
-              }
-              if (frame.done) {
-                // Stop the elapsed-time ticker and show the final duration.
+            // Stream via the shared helper (also used by the C0R3 rebuild-queue
+            // live-log panel); it owns the append / sticky-scroll /
+            // stderr-separator / reconnect-replay logic. We wire the badge +
+            // the row's elapsed-time ticker here.
+            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 = frame.status === 'ok' ? 'badge badge-ok' : 'badge badge-fail';
-                badge.textContent = frame.status || 'done';
-                streamEs.close(); streamEs = null;
+                badge.className = status === 'ok' ? 'badge badge-ok' : 'badge badge-fail';
+                badge.textContent = status;
+                streamEs = null;
                 detail.dataset.loaded = '1';
-              }
-            };
-            streamEs.onerror = () => {
-              if (streamEs && streamEs.readyState === EventSource.CONNECTING) {
-                pre.textContent = ''; stdoutLen = 0; stderrLen = 0;
-              } else {
+              },
+              onError: () => {
                 if (durTimer) { clearInterval(durTimer); durTimer = null; }
                 badge.textContent = 'stream error';
                 badge.className = 'badge badge-fail';
-                if (streamEs) { streamEs.close(); streamEs = null; }
-              }
-            };
+                streamEs = null;
+              },
+            });
           } else {
             const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
             detail.append(pre);