Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e797b75ca9 | ||
|
|
93fa264bbf |
6 changed files with 241 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -305,6 +305,52 @@ export function openStream(url) {
|
|||
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 ─────────────────────────────────────────────────────────
|
||||
// Singleton drawer that swipes in from the right. Long content
|
||||
// (file previews, approval diffs, journald logs, applied config)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
<div id="rebuild-queue-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<!-- Live build log of the currently-running rebuild (one runs at a
|
||||
time). Managed by renderRebuildLiveLog in core.js, separate from
|
||||
rebuild-queue-section so the queue's row re-render never disturbs
|
||||
the open SSE stream. -->
|
||||
<div id="rebuild-live-log" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue