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:
parent
65f385a7a0
commit
cc6227ccb5
4 changed files with 1517 additions and 33 deletions
1343
docs/web-ui.md
1343
docs/web-ui.md
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue