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
|
|
@ -2,11 +2,14 @@
|
|||
//
|
||||
// dist/index.html served by the Rust router at GET /
|
||||
// dist/flow.html served at GET /flow.html
|
||||
// dist/logs.html served at GET /logs.html
|
||||
// dist/static/tabs.js /index.html entry — tab renderers +
|
||||
// tab routing + refreshState
|
||||
// dist/static/flow.js /flow.html entry — broker terminal +
|
||||
// operator inbox + @-mention composer
|
||||
// dist/static/{tabs,flow}.js.map source map siblings
|
||||
// dist/static/logs.js /logs.html entry — build/agent/system
|
||||
// log viewer sub-tabs
|
||||
// dist/static/{tabs,flow,logs}.js.map source map siblings
|
||||
// dist/static/dashboard.css served at /static/dashboard.css
|
||||
// (@import resolved from @hive/shared)
|
||||
//
|
||||
|
|
@ -35,7 +38,7 @@ mkdirSync(staticDir(''), { recursive: true });
|
|||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src('tabs.js'), src('flow.js')],
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
|
|
@ -78,7 +81,7 @@ await build({
|
|||
logLevel: 'info',
|
||||
});
|
||||
|
||||
for (const html of ['index.html', 'flow.html']) {
|
||||
for (const html of ['index.html', 'flow.html', 'logs.html']) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1856,6 +1856,94 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
surface the messages via the pill/flyout instead. */
|
||||
.flow-inbox-headless { display: none !important; }
|
||||
|
||||
/* ─── /logs.html — log viewer ──────────────────────────────────────
|
||||
Same minimal-chrome pattern as /flow.html but with a sub-tab strip
|
||||
instead of a full dashboard tabbar. */
|
||||
|
||||
body.logs-shell {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body.flow-shell .flow-main-slim {
|
||||
padding-top: calc(var(--flow-header-h) + 0.4em);
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 25;
|
||||
background: rgba(30, 30, 46, 0.92);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
border-bottom: 1px solid var(--purple-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5em;
|
||||
padding: 0.5em 1.5em;
|
||||
}
|
||||
|
||||
.logs-back {
|
||||
color: var(--mauve);
|
||||
text-decoration: none;
|
||||
font-size: 0.88em;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.logs-back:hover { text-decoration: underline; }
|
||||
|
||||
.logs-title {
|
||||
color: var(--subtext0);
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.logs-tabbar {
|
||||
display: flex;
|
||||
gap: 0.2em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logs-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35em 0.9em;
|
||||
border-radius: 4px;
|
||||
color: var(--subtext0);
|
||||
text-decoration: none;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background 100ms, color 100ms;
|
||||
}
|
||||
.logs-tab:hover { background: var(--surface1); color: var(--text); }
|
||||
.logs-tab.logs-tab-active {
|
||||
background: var(--surface1);
|
||||
color: var(--mauve);
|
||||
}
|
||||
|
||||
.logs-main {
|
||||
padding: 1.2em 1.5em 2em;
|
||||
}
|
||||
|
||||
.logs-pane[hidden] { display: none; }
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
align-items: center;
|
||||
margin-bottom: 0.9em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logs-toolbar .journal-unit {
|
||||
background: var(--surface1);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--surface2);
|
||||
border-radius: 4px;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||
Creation form at the top, list of queued schedule cards below.
|
||||
Cards show: id + source + due-in + cancel-all in the header,
|
||||
|
|
|
|||
|
|
@ -8,46 +8,13 @@
|
|||
</head>
|
||||
<body class="flow-shell">
|
||||
|
||||
<!-- Fixed-overlay chrome — just the tab strip. The full-viewport
|
||||
terminal has no normal-flow footer position so the dashboard's
|
||||
slug doesn't appear here. The operator can still switch tabs
|
||||
from the flow page without navigating back; FL0W is the current
|
||||
page, SW4RM / Y3R C4LL / SYST3M / SCH3DUL3S cross-link to the
|
||||
dashboard with the matching hash. -->
|
||||
<header class="dashboard-chrome flow-chrome" id="flow-header">
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a class="tab" href="/#swarm" role="tab" data-tab="swarm">
|
||||
<span class="tab-label">◆ SW4RM ◆</span>
|
||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#call" role="tab" data-tab="call">
|
||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#system" role="tab" data-tab="system">
|
||||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#schedules" role="tab" data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
|
||||
aria-current="page"
|
||||
title="all-agents chat — you are here">
|
||||
<span class="tab-label">◆ FL0W ◆</span>
|
||||
<span class="tab-count" id="tab-count-flow" hidden></span>
|
||||
</a>
|
||||
<!-- S3TT1NGS cross-links to the dashboard tab — notification
|
||||
toggle + future preferences live there now. `NOTIF.show()`
|
||||
in common.js doesn't depend on the buttons existing here,
|
||||
so notifications still fire on this page when they're
|
||||
enabled (or muted) in dashboard settings. -->
|
||||
<a class="tab tab-link" href="/#settings"
|
||||
title="open S3TT1NGS on the dashboard">
|
||||
<span class="tab-label">◆ S3TT1NGS ◆ →</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Minimal chrome: just a back link. No full tabbar — the main
|
||||
dashboard already has the tab strip. The flow page is a
|
||||
dedicated full-viewport terminal surface; navigating back
|
||||
is the only chrome needed. -->
|
||||
<header class="logs-header">
|
||||
<a class="logs-back" href="/">← dashboard</a>
|
||||
<span class="logs-title">FL0W</span>
|
||||
</header>
|
||||
|
||||
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||
|
|
@ -63,7 +30,7 @@
|
|||
<!-- Main content: the full-viewport terminal. Padded for the
|
||||
overlay header + composer so the first/last rows stay
|
||||
reachable. -->
|
||||
<main class="flow-main">
|
||||
<main class="flow-main flow-main-slim">
|
||||
<div class="terminal-wrap">
|
||||
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@
|
|||
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
||||
</a>
|
||||
|
||||
<!-- L0GS is its own page (`/logs.html`). Links to the dedicated
|
||||
log viewer with three sub-tabs (build history, agent journal,
|
||||
system services). Same nav pattern as FL0W. -->
|
||||
<a class="tab tab-link" id="tab-logs" href="/logs.html"
|
||||
title="open the log viewer">
|
||||
<span class="tab-label">◆ L0GS ◆ →</span>
|
||||
</a>
|
||||
|
||||
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
|
||||
lives in the tab strip so it reads as a peer surface;
|
||||
clicking navigates rather than swapping panes in place.
|
||||
|
|
|
|||
78
frontend/packages/dashboard/src/logs.html
Normal file
78
frontend/packages/dashboard/src/logs.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive // LOGS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/dashboard.css">
|
||||
</head>
|
||||
<body class="logs-shell">
|
||||
|
||||
<!-- Minimal chrome: back link + sub-tab strip.
|
||||
Same pattern as flow.html — no full dashboard tabbar. -->
|
||||
<header class="logs-header">
|
||||
<a class="logs-back" href="/">← dashboard</a>
|
||||
<nav class="logs-tabbar" id="logs-tabbar" role="tablist">
|
||||
<a class="logs-tab" id="logs-tab-build" href="#build" role="tab"
|
||||
aria-controls="logs-pane-build" data-logs-tab="build">
|
||||
<span class="logs-tab-label">BUILD</span>
|
||||
</a>
|
||||
<a class="logs-tab" id="logs-tab-agent" href="#agent" role="tab"
|
||||
aria-controls="logs-pane-agent" data-logs-tab="agent">
|
||||
<span class="logs-tab-label">AGENT</span>
|
||||
</a>
|
||||
<a class="logs-tab" id="logs-tab-system" href="#system" role="tab"
|
||||
aria-controls="logs-pane-system" data-logs-tab="system">
|
||||
<span class="logs-tab-label">SYSTEM</span>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="logs-main">
|
||||
|
||||
<!-- BUILD: all-agents build log history. Lists recent nix build /
|
||||
nixos-container invocations across all agents, with
|
||||
click-to-expand full stdout+stderr. Backed by
|
||||
GET /api/build-logs?limit=30. -->
|
||||
<section class="logs-pane" id="logs-pane-build"
|
||||
role="tabpanel" aria-labelledby="logs-tab-build">
|
||||
<div class="logs-toolbar">
|
||||
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
||||
</div>
|
||||
<div id="build-list"><p class="meta">loading…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- AGENT: journald viewer for a specific agent container.
|
||||
Agent selector + unit filter + line count. Backed by
|
||||
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
||||
<section class="logs-pane" id="logs-pane-agent"
|
||||
role="tabpanel" aria-labelledby="logs-tab-agent">
|
||||
<div class="logs-toolbar">
|
||||
<select id="agent-select" class="journal-unit"></select>
|
||||
<select id="agent-unit-select" class="journal-unit">
|
||||
<option value="hive-ag3nt.service">hive-ag3nt.service</option>
|
||||
<option value="">(full machine journal)</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button>
|
||||
</div>
|
||||
<pre id="agent-output" class="journal-output">select an agent above</pre>
|
||||
</section>
|
||||
|
||||
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
||||
journal via GET /api/journal-host?unit=hive-c0re.service. -->
|
||||
<section class="logs-pane" id="logs-pane-system"
|
||||
role="tabpanel" aria-labelledby="logs-tab-system">
|
||||
<div class="logs-toolbar">
|
||||
<select id="system-unit-select" class="journal-unit">
|
||||
<option value="hive-c0re.service">hive-c0re.service</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button>
|
||||
</div>
|
||||
<pre id="system-output" class="journal-output">loading…</pre>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/logs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
259
frontend/packages/dashboard/src/logs.js
Normal file
259
frontend/packages/dashboard/src/logs.js
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// /logs.html entry point: log viewer with three sub-tabs (BUILD, AGENT, SYSTEM).
|
||||
// BUILD — all-agents build log history, backed by GET /api/build-logs
|
||||
// AGENT — per-container journald viewer, backed by GET /api/journal/{name}
|
||||
// SYSTEM — host service logs, backed by GET /api/journal-host
|
||||
//
|
||||
// Tab routing via URL hash (#build, #agent, #system). Default: #build.
|
||||
|
||||
import {
|
||||
$, el, fmtAgeSecs,
|
||||
} from './common.js';
|
||||
|
||||
(() => {
|
||||
// ─── tab routing ──────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['build', 'agent', 'system'];
|
||||
|
||||
function activeTab() {
|
||||
const hash = location.hash.replace('#', '');
|
||||
return TABS.includes(hash) ? hash : 'build';
|
||||
}
|
||||
|
||||
function showTab(name) {
|
||||
for (const t of TABS) {
|
||||
const pane = document.getElementById('logs-pane-' + t);
|
||||
const tab = document.getElementById('logs-tab-' + t);
|
||||
const active = t === name;
|
||||
if (pane) pane.hidden = !active;
|
||||
if (tab) {
|
||||
tab.classList.toggle('logs-tab-active', active);
|
||||
tab.setAttribute('aria-selected', String(active));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const tab = activeTab();
|
||||
showTab(tab);
|
||||
if (tab === 'system') fetchSystem();
|
||||
});
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTs(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
try {
|
||||
const d = new Date(isoStr);
|
||||
const age = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
return fmtAgeSecs(age) + ' ago';
|
||||
} catch { return isoStr; }
|
||||
}
|
||||
|
||||
function fmtDuration(secs) {
|
||||
if (secs < 60) return secs + 's';
|
||||
const m = Math.floor(secs / 60), s = secs % 60;
|
||||
return m + 'm ' + s + 's';
|
||||
}
|
||||
|
||||
// ─── BUILD tab ────────────────────────────────────────────────────────
|
||||
|
||||
const buildList = $('build-list');
|
||||
const buildRefresh = $('build-refresh');
|
||||
|
||||
async function fetchBuild() {
|
||||
if (!buildList) return;
|
||||
buildList.innerHTML = '';
|
||||
buildList.append(el('p', { class: 'meta' }, 'loading…'));
|
||||
try {
|
||||
const resp = await fetch('/api/build-logs?limit=30');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const rows = await resp.json();
|
||||
buildList.innerHTML = '';
|
||||
if (!rows || rows.length === 0) {
|
||||
buildList.append(el('p', { class: 'meta' }, '(no build logs yet)'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'build-logs-list' });
|
||||
for (const h of rows) {
|
||||
const li = el('li', { class: 'build-logs-item' });
|
||||
const ok = h.status === 'ok';
|
||||
const live = h.status === 'running';
|
||||
const statusClass = ok ? 'build-logs-ok' : live ? 'build-logs-live-badge badge badge-running' : 'build-logs-fail';
|
||||
const statusLabel = ok ? '✓' : live ? 'live' : '✗';
|
||||
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at));
|
||||
const runtime = h.runtime_secs != null
|
||||
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
|
||||
: live
|
||||
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…')
|
||||
: el('span', { class: 'build-logs-runtime meta' }, '');
|
||||
const rowBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'build-logs-row-btn',
|
||||
'aria-expanded': 'false',
|
||||
},
|
||||
el('span', { class: statusClass }, statusLabel),
|
||||
el('span', { class: 'build-logs-agent' }, h.agent),
|
||||
runtime,
|
||||
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 streamEs = null;
|
||||
rowBtn.addEventListener('click', async () => {
|
||||
const expanded = rowBtn.getAttribute('aria-expanded') === 'true';
|
||||
rowBtn.setAttribute('aria-expanded', String(!expanded));
|
||||
detail.hidden = expanded;
|
||||
if (expanded) {
|
||||
if (streamEs) { streamEs.close(); streamEs = null; }
|
||||
return;
|
||||
}
|
||||
// already loaded?
|
||||
if (detail.dataset.loaded) return;
|
||||
detail.innerHTML = '';
|
||||
detail.append(
|
||||
el('a', {
|
||||
href: '/api/build-logs/id/' + h.id + '/raw',
|
||||
download: 'build-log-' + h.id + '.txt',
|
||||
class: 'build-logs-dl',
|
||||
}, '↓ download raw'),
|
||||
);
|
||||
if (live) {
|
||||
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
|
||||
const badge = el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live');
|
||||
detail.append(badge, pre);
|
||||
streamEs = new EventSource('/api/build-logs/id/' + h.id + '/stream');
|
||||
streamEs.addEventListener('chunk', (e) => {
|
||||
try { pre.textContent += JSON.parse(e.data).text; } catch { /**/ }
|
||||
});
|
||||
streamEs.addEventListener('done', () => {
|
||||
badge.textContent = 'done';
|
||||
badge.className = 'build-logs-live-badge badge';
|
||||
streamEs.close(); streamEs = null;
|
||||
detail.dataset.loaded = '1';
|
||||
});
|
||||
streamEs.addEventListener('error', () => {
|
||||
badge.textContent = 'stream error';
|
||||
badge.className = 'build-logs-live-badge badge';
|
||||
streamEs.close(); streamEs = null;
|
||||
});
|
||||
} else {
|
||||
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
|
||||
detail.append(pre);
|
||||
try {
|
||||
const r2 = await fetch('/api/build-logs/id/' + h.id);
|
||||
if (!r2.ok) {
|
||||
pre.textContent = 'error ' + r2.status;
|
||||
} else {
|
||||
const full = await r2.json();
|
||||
const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n');
|
||||
pre.textContent = out || '(empty)';
|
||||
}
|
||||
detail.dataset.loaded = '1';
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
}
|
||||
}
|
||||
});
|
||||
li.append(rowBtn, detail);
|
||||
ul.append(li);
|
||||
}
|
||||
buildList.append(ul);
|
||||
} catch (err) {
|
||||
buildList.innerHTML = '';
|
||||
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
}
|
||||
}
|
||||
|
||||
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
|
||||
|
||||
// ─── AGENT tab ────────────────────────────────────────────────────────
|
||||
|
||||
const agentSelect = $('agent-select');
|
||||
const agentUnitSelect = $('agent-unit-select');
|
||||
const agentRefresh = $('agent-refresh');
|
||||
const agentOutput = $('agent-output');
|
||||
|
||||
// Populate agent selector from /api/state
|
||||
async function loadAgentList() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) return;
|
||||
const state = await resp.json();
|
||||
if (!agentSelect) return;
|
||||
agentSelect.innerHTML = '';
|
||||
agentSelect.append(el('option', { value: '' }, '— select agent —'));
|
||||
for (const c of (state.containers || [])) {
|
||||
agentSelect.append(el('option', { value: c.name }, c.name));
|
||||
}
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
let agentFetching = false;
|
||||
async function fetchAgent() {
|
||||
if (!agentSelect || !agentOutput) return;
|
||||
const name = agentSelect.value;
|
||||
if (!name) { agentOutput.textContent = 'select an agent above'; return; }
|
||||
if (agentFetching) return;
|
||||
agentFetching = true;
|
||||
agentOutput.textContent = 'fetching…';
|
||||
const unit = agentUnitSelect ? agentUnitSelect.value : '';
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
if (unit) params.set('unit', unit);
|
||||
try {
|
||||
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
|
||||
const text = await resp.text();
|
||||
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
|
||||
agentOutput.scrollTop = agentOutput.scrollHeight;
|
||||
} catch (err) {
|
||||
agentOutput.textContent = 'fetch failed: ' + err;
|
||||
} finally {
|
||||
agentFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentSelect) agentSelect.addEventListener('change', fetchAgent);
|
||||
if (agentUnitSelect) agentUnitSelect.addEventListener('change', fetchAgent);
|
||||
if (agentRefresh) agentRefresh.addEventListener('click', fetchAgent);
|
||||
|
||||
// ─── SYSTEM tab ───────────────────────────────────────────────────────
|
||||
|
||||
const systemUnitSelect = $('system-unit-select');
|
||||
const systemRefresh = $('system-refresh');
|
||||
const systemOutput = $('system-output');
|
||||
|
||||
let systemFetching = false;
|
||||
async function fetchSystem() {
|
||||
if (!systemOutput) return;
|
||||
if (systemFetching) return;
|
||||
systemFetching = true;
|
||||
systemOutput.textContent = 'fetching…';
|
||||
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
if (unit) params.set('unit', unit);
|
||||
try {
|
||||
const resp = await fetch('/api/journal-host?' + params);
|
||||
const text = await resp.text();
|
||||
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
|
||||
systemOutput.scrollTop = systemOutput.scrollHeight;
|
||||
} catch (err) {
|
||||
systemOutput.textContent = 'fetch failed: ' + err;
|
||||
} finally {
|
||||
systemFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
|
||||
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tab = activeTab();
|
||||
showTab(tab);
|
||||
loadAgentList();
|
||||
fetchBuild();
|
||||
if (tab === 'system') fetchSystem();
|
||||
|
||||
})();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -318,6 +318,25 @@ impl BuildLogs {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Return the most recent `limit` rows across all agents, newest first.
|
||||
/// Same header-only shape as `list_recent_for_agent`. Limit clamped to 100.
|
||||
pub fn list_recent_all(&self, limit: usize) -> Result<Vec<BuildLogHeader>> {
|
||||
let limit = limit.min(100);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, cmdline, started_at, finished_at, status
|
||||
FROM build_logs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(100)], row_to_header)?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fetch a single full row (with stdout/stderr text) by id.
|
||||
/// Returns `None` when the id doesn't exist (vacuum sweep already
|
||||
/// reaped it, or the operator passed a stale id from a refresh
|
||||
|
|
|
|||
|
|
@ -63,9 +63,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||
.route("/api/journal/{name}", get(get_journal))
|
||||
.route("/api/journal-host", get(get_journal_host))
|
||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||
.route("/api/state-file", get(get_state_file))
|
||||
.route("/api/reminders", get(api_reminders))
|
||||
.route("/api/build-logs", get(get_build_logs_all))
|
||||
.route("/api/build-logs/{agent}", get(get_build_logs_agent))
|
||||
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
||||
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
|
||||
|
|
@ -1183,6 +1185,72 @@ async fn get_journal(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JournalHostQuery {
|
||||
/// Service unit name to filter to. If omitted, returns all logs.
|
||||
#[serde(default)]
|
||||
unit: Option<String>,
|
||||
/// Number of trailing lines. Capped at 5000. Default 500.
|
||||
#[serde(default)]
|
||||
lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// `GET /api/journal-host?unit=<unit>&lines=N` — host-side journald (no
|
||||
/// `-M` container flag). Restricted to an allow-list of known host services
|
||||
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
|
||||
/// dashboard binding to a host-only port.
|
||||
async fn get_journal_host(
|
||||
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
||||
) -> Response {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
let allowed = ["hive-c0re.service"];
|
||||
let mut cmd = tokio::process::Command::new("journalctl");
|
||||
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
||||
.arg(lines.to_string());
|
||||
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return error_response(&format!("journal-host: unknown unit {unit:?}"));
|
||||
}
|
||||
cmd.args(["-u", &unit]);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(out) => {
|
||||
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if !out.status.success() {
|
||||
body.push_str("\n--- stderr ---\n");
|
||||
body.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("journalctl spawn: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BuildLogsAllQuery {
|
||||
/// Max rows to return. Capped at 100. Default 30.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /api/build-logs?limit=N` — most-recent build log headers across
|
||||
/// all agents, newest first. Same JSON shape as the per-agent endpoint.
|
||||
async fn get_build_logs_all(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
|
||||
) -> Response {
|
||||
let limit = q.limit.unwrap_or(30);
|
||||
match state.coord.build_logs.list_recent_all(limit) {
|
||||
Ok(rows) => axum::Json(rows).into_response(),
|
||||
Err(e) => error_response(&format!("build-logs all: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StateFileQuery {
|
||||
path: String,
|
||||
|
|
|
|||
Loading…
Reference in a new issue