From 5fe750eaadec600fe1fa6962a811a159a1a09cbc Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:49:28 +0200 Subject: [PATCH] feat(logs): auto-scroll live build output, elapsed-time ticker, SSE-driven list refresh Three UX improvements for the live build log viewer: 1. Auto-scroll (sticky-bottom): live build output now scrolls to the bottom as new lines arrive. Stops auto-scrolling when the operator manually scrolls up; resumes when they scroll back to the bottom. Same intent-tracking pattern used by the terminal pane. 2. Elapsed-time ticker: running builds show a live seconds/minutes counter in the row header that ticks every second. Stops and shows the final duration when the build finishes (done frame received) or errors out. 3. SSE-driven list refresh: subscribes to /dashboard/stream and debounces a fetchBuild() call (2s) whenever rebuild_queue_changed fires while the BUILD tab is active. New log entries and status changes appear without a manual refresh. --- frontend/packages/dashboard/src/logs.js | 64 ++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index 4e9b4b4a..b334d1c8 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -4,9 +4,18 @@ // SYSTEM — host service logs, backed by GET /api/journal-host // // Tab routing via URL hash (#build, #agent, #system). Default: #build. +// +// Live improvements: +// - Live build output auto-scrolls to the bottom (sticky-bottom) unless +// the operator has manually scrolled up. +// - Running builds show an elapsed-time counter in the row header that +// ticks every second. +// - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed +// fires from the dashboard SSE stream, so new log entries appear without +// a manual page refresh. import { - $, el, fmtAgeSecs, + $, el, fmtAgeSecs, openStream, } from './common.js'; (() => { @@ -85,6 +94,20 @@ import { : live ? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…') : el('span', { class: 'build-logs-runtime meta' }, ''); + + // For live builds, start an elapsed-time ticker immediately in the + // row header so the operator can see how long the build has been + // running without opening the log detail. + let durTimer = null; + if (live && h.started_at) { + const updateDur = () => { + const elapsed = Math.floor(Date.now() / 1000) - h.started_at; + runtime.textContent = fmtDuration(Math.max(0, elapsed)); + }; + updateDur(); // show initial value right away + durTimer = setInterval(updateDur, 1000); + } + const rowBtn = el('button', { type: 'button', class: 'build-logs-row-btn', @@ -123,6 +146,15 @@ import { const pre = el('pre', { class: 'build-logs-output build-logs-live' }, ''); 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) => { @@ -130,14 +162,22 @@ import { 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. + 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; @@ -148,6 +188,7 @@ import { if (streamEs && streamEs.readyState === EventSource.CONNECTING) { pre.textContent = ''; stdoutLen = 0; stderrLen = 0; } else { + if (durTimer) { clearInterval(durTimer); durTimer = null; } badge.textContent = 'stream error'; badge.className = 'badge badge-fail'; if (streamEs) { streamEs.close(); streamEs = null; } @@ -197,6 +238,27 @@ import { if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild); + // Auto-refresh the build list when the dashboard SSE stream signals that + // the rebuild queue changed — this surfaces new log entries and status + // changes without needing a manual page refresh. Debounce 2s so a burst + // of rapid queue mutations only triggers one fetch. + { + let buildRefreshTimer = null; + const debouncedRefreshBuild = () => { + if (activeTab() !== 'build') return; + if (buildRefreshTimer) clearTimeout(buildRefreshTimer); + buildRefreshTimer = setTimeout(fetchBuild, 2000); + }; + const es = openStream('/dashboard/stream'); + if (es) { + es.onmessage = (e) => { + let ev; + try { ev = JSON.parse(e.data); } catch { return; } + if (ev.kind === 'rebuild_queue_changed') debouncedRefreshBuild(); + }; + } + } + // ─── AGENT tab ──────────────────────────────────────────────────────── const agentSelect = $('agent-select');