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

File diff suppressed because it is too large Load diff

View file

@ -369,6 +369,15 @@ fetch entirely.
layout so the `<pre>` log surface fills the full remaining panel
height; scroll happens inside the `<pre>`, not the side
panel body.
- `↳ build logs · <agent>` — opens the side panel and fetches
the last 10 build-log headers via
`GET /api/build-logs/{agent}` (status chip + kind + age +
truncated cmdline per row). Clicking a row lazy-fetches its
full stdout+stderr from `GET /api/build-logs/id/{id}` and
expands it inline as a scrollable `<pre>`. A refresh button
re-fetches the header list. Backed by the `build_logs.sqlite`
store that `lifecycle::run` and `lifecycle::prebuild_toplevel`
write into.
- Plain navigation links (config repo, forge profile,
`dashboardLinks` extras) now live in the icon-only nav strip
on Line 1 — see above. The agent's `config` link

View file

@ -504,6 +504,90 @@ a:hover {
word-break: normal;
}
/* Build-log side-panel viewer. Same general shape as the journald
viewer: a toolbar above a scrollable list of log header rows;
clicking a row expands full stdout+stderr inline. */
.build-logs-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 0.4em;
}
.build-logs-toolbar {
flex: 0 0 auto;
display: flex;
gap: 0.5em;
align-items: center;
}
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.build-logs-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25em;
}
.build-logs-loading,
.build-logs-empty,
.build-logs-error {
padding: 0.4em 0.6em;
font-size: 0.85em;
color: var(--subtext0);
}
.build-logs-error { color: var(--red); }
.build-logs-item {
border: 1px solid var(--border);
border-radius: 2px;
}
.build-logs-row-btn {
width: 100%;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
cursor: pointer;
padding: 0.35em 0.6em;
text-align: left;
display: flex;
gap: 0.5em;
align-items: center;
flex-wrap: wrap;
}
.build-logs-row-btn:hover { background: var(--bg-elev); }
.build-logs-kind { font-weight: 600; }
.build-logs-age { font-size: 0.88em; }
.build-logs-cmdline {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
}
.build-logs-detail { padding: 0 0.4em 0.4em; }
.build-logs-output {
margin: 0;
background: #11111b;
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em;
overflow: auto;
font-size: 0.8em;
line-height: 1.4;
white-space: pre;
word-break: normal;
max-height: 60vh;
}
/* Status badge colours reused for build status chips. */
.badge-ok { background: rgba(166,227,161,0.12); color: var(--green); border-color: var(--green); }
.badge-fail { background: rgba(243,139,168,0.12); color: var(--red); border-color: var(--red); }
.badge-running { background: rgba(250,179,135,0.12); color: var(--amber); border-color: var(--amber); }
/* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */

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);