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);