dashboard: build-log side-panel viewer in agent card (#726 phase 3)

Adds a `↳ build logs · {agent}` drill-in to every agent card that
opens the side panel and fetches the last 10 build-log headers from
GET /api/build-logs/{agent}. Each row shows a status chip (ok/fail/
running), kind, age, and truncated cmdline. Clicking a row lazily
fetches the full stdout+stderr from GET /api/build-logs/id/{id} and
expands it inline as a scrollable pre.

CSS additions: .build-logs-{panel,toolbar,list,item,row-btn,...} plus
.badge-ok/.badge-fail/.badge-running status chips shared with future
uses. docs/web-ui.md updated with the new drill-in and the new badge
classes.
This commit is contained in:
iris 2026-05-31 20:30:57 +02:00 committed by mara
commit cc6227ccb5
4 changed files with 1517 additions and 33 deletions

View file

@ -709,6 +709,10 @@ window.marked = marked;
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : '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
@ -1068,6 +1072,110 @@ window.marked = marked;
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 rowBtn = el('button',
{ type: 'button', class: 'build-logs-row-btn' },
el('span', { class: `badge ${statusCls}` }, statusLabel),
el('span', { class: 'build-logs-kind' }, h.kind),
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;
let loaded = false;
rowBtn.addEventListener('click', async () => {
if (!detail.hidden) { detail.hidden = true; return; }
if (!loaded) {
detail.textContent = 'fetching…';
detail.hidden = false;
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 {
detail.hidden = false;
}
});
li.append(rowBtn, 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);