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:
parent
c4ce91b61f
commit
050e130eba
5 changed files with 365 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue