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/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.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..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'; @@ -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,100 @@ 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); + + // 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) ────────────────────────────────────────────── function renderTombstones(s) { const root = $('tombstones-section'); 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);