dashboard: stream the running rebuild's build log inline in the queue
The C0R3 rebuild queue only linked out to the logs page (logs →). Add an
inline live-log panel under the queue that streams the currently-running
rebuild's build output, so the operator watches progress without leaving the
page.
Extract the build-log SSE streaming logic (append stdout/stderr, sticky-bottom
scroll, stderr separator, reconnect-replay reset, done/error handling) into a
shared `openBuildLogStream(id, pre, {onDone, onError})` in common.js, and use it
from BOTH the L0GS page BUILD tab (logs.js, previously inline) and the new C0R3
panel (core.js) — one implementation, no duplication.
The panel is one persistent instance keyed to the running entry's build_log_id
(the queue runs one build at a time), in its own container (#rebuild-live-log)
outside rebuild-queue-section so the queue's per-row re-render — rows rebuild as
the build step advances — never tears down the open stream; it reconnects only
when the running build_log_id changes and won't reopen a stream that already
sent done. Collapsible, live/ok/fail badge, raw download. Hidden when nothing
is building; each row keeps its logs → link for full history.
Frontend-only — no backend change (endpoint + build_log_id already existed).
Closes #1860.
This commit is contained in:
parent
93fa264bbf
commit
e797b75ca9
3 changed files with 79 additions and 78 deletions
|
|
@ -305,6 +305,52 @@ export function openStream(url) {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream a build log into a <pre>, 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 <pre> 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 <pre> 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 ─────────────────────────────────────────────────────────
|
// ─── side panel ─────────────────────────────────────────────────────────
|
||||||
// Singleton drawer that swipes in from the right. Long content
|
// Singleton drawer that swipes in from the right. Long content
|
||||||
// (file previews, approval diffs, journald logs, applied config)
|
// (file previews, approval diffs, journald logs, applied config)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
||||||
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
// 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 { fmtAgo, fmtElapsed, truncate } from './util.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
|
@ -369,45 +369,24 @@ function renderRebuildLiveLog(queue) {
|
||||||
);
|
);
|
||||||
root.append(header, pre);
|
root.append(header, pre);
|
||||||
|
|
||||||
// Sticky-bottom: auto-scroll to bottom on new lines unless the operator
|
// Stream via the shared helper (same path the L0GS BUILD tab uses). It owns
|
||||||
// scrolled up (track intent via the scroll event — same as the logs page).
|
// the append / sticky-scroll / stderr-separator / reconnect-replay logic; we
|
||||||
let atBottom = true;
|
// just wire the badge. onDone keeps liveLogId + liveLogDone set so a
|
||||||
pre.addEventListener('scroll', () => {
|
// re-render before the entry leaves 'running' won't reopen the finished
|
||||||
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
// stream — the next render with no running entry clears the panel.
|
||||||
});
|
liveLogEs = openBuildLogStream(running.build_log_id, pre, {
|
||||||
|
onDone: (status) => {
|
||||||
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) {
|
|
||||||
liveLogDone = true;
|
liveLogDone = true;
|
||||||
badge.className = 'rebuild-live-log-badge '
|
badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail');
|
||||||
+ (frame.status === 'ok' ? 'rll-ok' : 'rll-fail');
|
badge.textContent = status;
|
||||||
badge.textContent = frame.status || 'done';
|
liveLogEs = null;
|
||||||
if (es === liveLogEs) { es.close(); liveLogEs = null; }
|
},
|
||||||
// Keep liveLogId + liveLogDone so a re-render before the entry leaves
|
onError: () => {
|
||||||
// 'running' doesn't reopen the finished stream; the next render with no
|
badge.className = 'rebuild-live-log-badge rll-fail';
|
||||||
// running entry clears the panel.
|
badge.textContent = 'stream error';
|
||||||
}
|
liveLogEs = null;
|
||||||
};
|
},
|
||||||
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; }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
// SYSTEM tabs so the operator knows how stale the output is.
|
// SYSTEM tabs so the operator knows how stale the output is.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
$, el, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.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');
|
const badge = el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live');
|
||||||
detail.append(badge, pre);
|
detail.append(badge, pre);
|
||||||
|
|
||||||
// Sticky-bottom: auto-scroll to the bottom as new lines arrive
|
// Stream via the shared helper (also used by the C0R3 rebuild-queue
|
||||||
// unless the operator has manually scrolled up. Track intent via
|
// live-log panel); it owns the append / sticky-scroll /
|
||||||
// the scroll event rather than recomputing on each append.
|
// stderr-separator / reconnect-replay logic. We wire the badge +
|
||||||
let atBottom = true;
|
// the row's elapsed-time ticker here.
|
||||||
pre.addEventListener('scroll', () => {
|
streamEs = openBuildLogStream(h.id, pre, {
|
||||||
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
onDone: (status) => {
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||||
if (h.started_at) {
|
if (h.started_at) {
|
||||||
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
|
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
|
||||||
runtime.textContent = fmtDuration(Math.max(0, elapsed));
|
runtime.textContent = fmtDuration(Math.max(0, elapsed));
|
||||||
}
|
}
|
||||||
badge.className = frame.status === 'ok' ? 'badge badge-ok' : 'badge badge-fail';
|
badge.className = status === 'ok' ? 'badge badge-ok' : 'badge badge-fail';
|
||||||
badge.textContent = frame.status || 'done';
|
badge.textContent = status;
|
||||||
streamEs.close(); streamEs = null;
|
streamEs = null;
|
||||||
detail.dataset.loaded = '1';
|
detail.dataset.loaded = '1';
|
||||||
}
|
},
|
||||||
};
|
onError: () => {
|
||||||
streamEs.onerror = () => {
|
|
||||||
if (streamEs && streamEs.readyState === EventSource.CONNECTING) {
|
|
||||||
pre.textContent = ''; stdoutLen = 0; stderrLen = 0;
|
|
||||||
} else {
|
|
||||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||||
badge.textContent = 'stream error';
|
badge.textContent = 'stream error';
|
||||||
badge.className = 'badge badge-fail';
|
badge.className = 'badge badge-fail';
|
||||||
if (streamEs) { streamEs.close(); streamEs = null; }
|
streamEs = null;
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
} else {
|
} else {
|
||||||
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
|
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
|
||||||
detail.append(pre);
|
detail.append(pre);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue