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:
parent
fd87cf9924
commit
0b15cad93f
9 changed files with 534 additions and 299 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue