feat(#986): dedicated logs page with build/agent/system sub-tabs

Add /logs.html as a standalone page (same back-link pattern as flow.html):
- BUILD tab: all-agents build log history via new GET /api/build-logs endpoint
- AGENT tab: per-container journald viewer with agent selector + unit filter
- SYSTEM tab: host-side hive-c0re.service logs via new GET /api/journal-host endpoint

Remove inline log drill-ins from SW4RM container rows (buildJournalTrigger
and buildBuildLogsTrigger) — log viewing now lives on the dedicated page.

flow.html: strip the full dashboard tabbar, replace with a simple back link
matching the new logs page chrome.

index.html: add L0GS tab link to /logs.html in the tab strip.

Backend additions:
- build_logs::list_recent_all — cross-agent query (newest first, cap 100)
- GET /api/build-logs — all-agents variant backed by list_recent_all
- GET /api/journal-host — host journald (no -M container flag), restricted
  to allow-listed units (hive-c0re.service)
This commit is contained in:
iris 2026-06-01 19:11:47 +02:00
commit 0b15cad93f
9 changed files with 534 additions and 299 deletions

View file

@ -841,24 +841,6 @@ window.marked = marked;
// chip in the head row stays — it's a state-hint, not an
// action button.
// ── drill-ins ────────────────────────────────────────────────
const drill = el('div', { class: 'drill-ins' });
// Per-container journald viewer. Opens the side panel and
// fetches the last N lines; refresh re-fetches; unit selector
// narrows to the harness service (or empty = full machine).
const journalUnit = 'hive-ag3nt.service';
drill.append(buildJournalTrigger(c.container, journalUnit));
// Build-log viewer: lists recent nix build / nixos-container
// invocations for this agent, with click-to-expand full
// stdout + stderr. Backed by GET /api/build-logs/{name}.
drill.append(buildBuildLogsTrigger(c.name));
// The hardcoded config-repo trigger and the agent-declared
// extras block both moved into the unified nav strip in the
// head row above (sourced from the agent backend via
// `/api/agent/{name}/links`). Only the journald trigger stays
// here since it opens the side panel rather than a link.
body.append(drill);
li.append(icon, body, buildAgentMenu(c));
ul.append(li);
}
@ -1157,243 +1139,6 @@ window.marked = marked;
parent.append(btn);
}
// Per-container journald viewer. Returns an inline trigger; the
// click opens the side panel and fetches the last N lines. Refresh
// re-fetches; the unit toggle switches between the harness service
// and the full machine journal.
function buildJournalTrigger(containerName, defaultUnit) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ logs · ' + containerName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll to the newest lines on fresh fetch. The
// scroll surface is the <pre> itself (panel-body fills
// the viewport, <pre> is the inner overflow container).
pre.scrollTop = pre.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
Panel.open('logs · ' + containerName, body);
fetchLogs();
});
return trigger;
}
// Build-log viewer. Fetches the last 10 build-log headers for
// `agentName` from GET /api/build-logs/{agentName}, then renders
// a clickable list. Clicking a row fetches its full stdout+stderr
// from GET /api/build-logs/id/{id} and expands it inline.
function buildBuildLogsTrigger(agentName) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ build logs · ' + agentName);
trigger.addEventListener('click', () => {
const panelTitle = 'build logs · ' + agentName;
const wrap = el('div', { class: 'build-logs-panel' });
const hdr = el('div', { class: 'build-logs-toolbar' });
const refreshBtn = el('button',
{ type: 'button', class: 'btn btn-restart build-logs-refresh' },
'↻ refresh');
hdr.append(refreshBtn);
wrap.append(hdr);
const list = el('ul', { class: 'build-logs-list' });
wrap.append(list);
let fetching = false;
async function loadHeaders() {
if (fetching) return;
fetching = true;
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-loading' }, 'fetching…'));
try {
const resp = await fetch('/api/build-logs/' + agentName + '?limit=10');
if (!resp.ok) {
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-error' },
'error ' + resp.status + ': ' + await resp.text()));
return;
}
const rows = await resp.json();
list.innerHTML = '';
if (!rows || !rows.length) {
list.append(el('li', { class: 'build-logs-empty' }, '(no build logs yet)'));
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
for (const h of rows) {
const li = el('li', { class: 'build-logs-item' });
const statusCls = h.status === 'ok' ? 'badge-ok'
: h.status === 'fail' ? 'badge-fail'
: 'badge-running';
const statusLabel = h.status === 'ok' ? 'ok'
: h.status === 'fail' ? 'fail'
: 'running';
const age = h.started_at
? fmtAgeSecs(nowUnix - h.started_at) + ' ago' : '?';
const runtimeSpan = h.runtime_secs != null
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
: h.started_at && !h.finished_at
? el('span', {
class: 'build-logs-runtime meta',
'data-bl-elapsed': String(h.started_at),
}, fmtElapsed(Math.max(0, nowUnix - h.started_at)))
: null;
const rowBtn = el('button',
{ type: 'button', class: 'build-logs-row-btn', 'aria-expanded': 'false' },
el('span', { class: `badge ${statusCls}` }, statusLabel),
el('span', { class: 'build-logs-kind' }, h.kind),
runtimeSpan,
el('span', { class: 'build-logs-age meta' }, age),
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline),
);
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;
}
// 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…';
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 pre = el('pre', { class: 'build-logs-output' }, out || '(empty)');
detail.replaceChildren(pre);
loaded = true;
}
} catch (err) {
detail.textContent = 'fetch failed: ' + err;
}
} else {
// ── 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.CONNECTING) {
// Auto-reconnect: clear accumulated content so the
// fresh stream from cursor=0 doesn't double-append.
pre.textContent = '';
stdoutLen = 0;
stderrLen = 0;
} else if (es.readyState === EventSource.CLOSED) {
detail._es = null;
}
};
}
});
li.append(rowBtn, dlLink, detail);
list.append(li);
}
} catch (err) {
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-error' }, 'fetch failed: ' + err));
} finally {
fetching = false;
}
}
refreshBtn.addEventListener('click', loadHeaders);
Panel.open(panelTitle, wrap);
loadHeaders();
});
return trigger;
}
function renderTombstones(s) {
const root = $('tombstones-section');
// #tombstones-section only lives on /index.html (SYST3M tab);