build-logs: SSE live streaming + raw download (#726 phase 4)

Backend (hive-c0re):
- build_logs.rs: add tokio::sync::broadcast::Sender<i64> to BuildLogs;
  append() and finish() notify subscribers after each sqlite write.
  Add BuildLogProgress struct and get_progress(id, stdout_cursor,
  stderr_cursor) for incremental delta reads.
- dashboard.rs: two new endpoints —
    GET /api/build-logs/id/{id}/stream  SSE; streams BuildLogFrame
      {stdout_append, stderr_append, status?, done} deltas until the
      build finishes or the browser disconnects. Backed by an mpsc
      channel task that watches the per-build broadcast notifications.
    GET /api/build-logs/id/{id}/raw    text/plain download with
      Content-Disposition: attachment; filename build-log-{agent}-{id}.txt

Frontend (dashboard):
- tabs.js: running builds (status === null) connect an EventSource to
  /stream and append lines live; "live" badge pulses amber while active,
  flips to ok/fail on done. Finished builds still use the JSON fetch path.
  Collapsing a running panel closes the EventSource; re-expanding
  reconnects. Adds a "⬇ raw" download link to every expanded row.
- dashboard.css: .build-logs-dl inline download link; .build-logs-live
  live pulse @keyframes animation.

Docs: web-ui.md updated for all three new endpoints + behaviour.
This commit is contained in:
iris 2026-05-31 21:23:01 +02:00 committed by mara
commit 050e130eba
5 changed files with 365 additions and 14 deletions

View file

@ -587,6 +587,28 @@ a:hover {
.badge-ok { background: rgba(166,227,161,0.12); color: var(--green); border-color: var(--green); }
.badge-fail { background: rgba(243,139,168,0.12); color: var(--red); border-color: var(--red); }
.badge-running { background: rgba(250,179,135,0.12); color: var(--amber); border-color: var(--amber); }
/* Download link sits inline after the row button. Shown only while
the detail is expanded (toggled by JS). */
.build-logs-dl {
display: none;
font-size: 0.72em;
padding: 0.15em 0.45em;
margin-left: 0.3em;
color: var(--muted);
text-decoration: none;
border: 1px solid var(--border);
border-radius: 3px;
}
.build-logs-dl:not([hidden]) { display: inline-block; }
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); }
/* Live-streaming indicator badge inside the detail pane header. */
.build-logs-live-badge { margin-bottom: 0.4em; }
/* Pulse animation on the "live" badge text while streaming. */
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are

View file

@ -1134,24 +1134,44 @@ window.marked = marked;
);
const detail = el('div', { class: 'build-logs-detail' });
detail.hidden = true;
// `loaded` stays false for running builds until SSE
// signals done — re-collapsing a running panel stops the
// stream and re-expanding reconnects it.
let loaded = false;
// Download link always points at the raw-text endpoint;
// hidden until the row is expanded for the first time.
const dlLink = el('a', {
href: '/api/build-logs/id/' + h.id + '/raw',
download: 'build-log-' + h.id + '.txt',
class: 'build-logs-dl',
hidden: '',
}, '⬇ raw');
rowBtn.addEventListener('click', async () => {
if (!detail.hidden) {
// Collapse: hide panel, close any live SSE stream.
detail.hidden = true;
dlLink.hidden = true;
rowBtn.setAttribute('aria-expanded', 'false');
if (detail._es) { detail._es.close(); detail._es = null; }
return;
}
if (!loaded) {
// Expand
detail.hidden = false;
dlLink.hidden = false;
rowBtn.setAttribute('aria-expanded', 'true');
if (loaded) return; // finished build, cached content ready
if (h.status) {
// ── finished build: fetch full JSON once ──────────────
detail.textContent = 'fetching…';
detail.hidden = false;
rowBtn.setAttribute('aria-expanded', 'true');
try {
const r2 = await fetch('/api/build-logs/id/' + h.id);
if (!r2.ok) {
detail.textContent = 'error ' + r2.status + ': ' + await r2.text();
} else {
const full = await r2.json();
const out = (full.stdout || '') + (full.stderr ? '\n--- stderr ---\n' + full.stderr : '');
const out = (full.stdout || '')
+ (full.stderr ? '\n--- stderr ---\n' + full.stderr : '');
const pre = el('pre', { class: 'build-logs-output' }, out || '(empty)');
detail.replaceChildren(pre);
loaded = true;
@ -1160,11 +1180,49 @@ window.marked = marked;
detail.textContent = 'fetch failed: ' + err;
}
} else {
detail.hidden = false;
rowBtn.setAttribute('aria-expanded', 'true');
// ── running build: stream via SSE ─────────────────────
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
detail.replaceChildren(
el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live'),
pre,
);
let stdoutLen = 0;
let stderrLen = 0;
const es = new EventSource('/api/build-logs/id/' + h.id + '/stream');
detail._es = es;
es.onmessage = (ev) => {
let frame;
try { frame = JSON.parse(ev.data); } catch { return; }
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
stdoutLen += frame.stdout_append.length;
}
if (frame.stderr_append) {
if (stderrLen === 0) pre.textContent += '\n--- stderr ---\n';
pre.textContent += frame.stderr_append;
stderrLen += frame.stderr_append.length;
}
if (frame.done) {
es.close();
detail._es = null;
// Replace live badge with final status
const badge = detail.querySelector('.build-logs-live-badge');
if (badge) {
badge.className = frame.status === 'ok'
? 'badge badge-ok' : 'badge badge-fail';
badge.textContent = frame.status || 'done';
}
loaded = true;
}
};
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
detail._es = null;
}
};
}
});
li.append(rowBtn, detail);
li.append(rowBtn, dlLink, detail);
list.append(li);
}
} catch (err) {