From 0b15cad93f5e29eb632b6f5fa583bdaae40784fd Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 19:11:47 +0200 Subject: [PATCH] feat(#986): dedicated logs page with build/agent/system sub-tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/packages/dashboard/build.mjs | 9 +- frontend/packages/dashboard/src/dashboard.css | 88 ++++++ frontend/packages/dashboard/src/flow.html | 49 +--- frontend/packages/dashboard/src/index.html | 8 + frontend/packages/dashboard/src/logs.html | 78 ++++++ frontend/packages/dashboard/src/logs.js | 259 ++++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 255 ----------------- hive-c0re/src/build_logs.rs | 19 ++ hive-c0re/src/dashboard.rs | 68 +++++ 9 files changed, 534 insertions(+), 299 deletions(-) create mode 100644 frontend/packages/dashboard/src/logs.html create mode 100644 frontend/packages/dashboard/src/logs.js diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 346f1125..a79a347b 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -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/.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)); } diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 29f59830..4ee4ba8d 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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, diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index a68185bc..25203161 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -8,46 +8,13 @@ - -
- + +
+ ← dashboard + FL0W
-
+
connecting…
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 89255843..fcac8f41 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -71,6 +71,14 @@ ◆ M4TR1X ◆ → + + + ◆ L0GS ◆ → + + +
+ ← dashboard + +
+ +
+ + +
+
+ +
+

loading…

+
+ + +
+
+ + + +
+
select an agent above
+
+ + +
+
+ + +
+
loading…
+
+ +
+ + + + diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js new file mode 100644 index 00000000..5d70b4ad --- /dev/null +++ b/frontend/packages/dashboard/src/logs.js @@ -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(); + +})(); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 2a6229b7..05cb5a9c 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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
 itself (panel-body fills
-            // the viewport, 
 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);
diff --git a/hive-c0re/src/build_logs.rs b/hive-c0re/src/build_logs.rs
index 1492dbd6..5f65e1ed 100644
--- a/hive-c0re/src/build_logs.rs
+++ b/hive-c0re/src/build_logs.rs
@@ -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> {
+        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
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 0d628817..e65069f5 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -63,9 +63,11 @@ pub async fn serve(port: u16, coord: Arc) -> 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,
+    /// Number of trailing lines. Capped at 5000. Default 500.
+    #[serde(default)]
+    lines: Option,
+}
+
+/// `GET /api/journal-host?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,
+) -> 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,
+}
+
+/// `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,
+    axum::extract::Query(q): axum::extract::Query,
+) -> 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,