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 layout so the `<pre>` log surface fills the full remaining panel
height; scroll happens inside the `<pre>`, not the side height; scroll happens inside the `<pre>`, not the side
panel body. 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, - Plain navigation links (config repo, forge profile,
`dashboardLinks` extras) now live in the icon-only nav strip `dashboardLinks` extras) now live in the icon-only nav strip
on Line 1 — see above. The agent's `config` link on Line 1 — see above. The agent's `config` link

View file

@ -504,6 +504,90 @@ a:hover {
word-break: normal; 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 /* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */ 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). // narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
drill.append(buildJournalTrigger(c.container, journalUnit)); 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 // The hardcoded config-repo trigger and the agent-declared
// extras block both moved into the unified nav strip in the // extras block both moved into the unified nav strip in the
// head row above (sourced from the agent backend via // head row above (sourced from the agent backend via
@ -1068,6 +1072,110 @@ window.marked = marked;
return trigger; 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) { function renderTombstones(s) {
const root = $('tombstones-section'); const root = $('tombstones-section');
// #tombstones-section only lives on /index.html (SYST3M tab); // #tombstones-section only lives on /index.html (SYST3M tab);