diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index a79a347b..346f1125 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -2,14 +2,11 @@ // // 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/logs.js /logs.html entry — build/agent/system -// log viewer sub-tabs -// dist/static/{tabs,flow,logs}.js.map source map siblings +// dist/static/{tabs,flow}.js.map source map siblings // dist/static/dashboard.css served at /static/dashboard.css // (@import resolved from @hive/shared) // @@ -38,7 +35,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'), src('logs.js')], + entryPoints: [src('tabs.js'), src('flow.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -81,7 +78,7 @@ await build({ logLevel: 'info', }); -for (const html of ['index.html', 'flow.html', 'logs.html']) { +for (const html of ['index.html', 'flow.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index f2019b52..29f59830 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1805,11 +1805,6 @@ body.flow-shell .tabbar .tab.active.tab-link { bottom: 0; overflow: hidden; } -/* flow.html now has a slim header (back link only) instead of the full - tabbar — reduce the top padding to match the smaller header height. */ -body.flow-shell .flow-main-slim { - padding-top: calc(var(--flow-header-h) + 0.4em); -} .flow-main .terminal-wrap { position: absolute; inset: 0; @@ -1861,91 +1856,6 @@ body.flow-shell .flow-main-slim { 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; -} - -.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 25203161..a68185bc 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -8,13 +8,46 @@ - -
- ← dashboard - FL0W + +
+
-
+
connecting…
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index fcac8f41..89255843 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -71,14 +71,6 @@ ◆ 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 deleted file mode 100644 index 6a768661..00000000 --- a/frontend/packages/dashboard/src/logs.js +++ /dev/null @@ -1,273 +0,0 @@ -// /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 ────────────────────────────────────────────────────────── - - // started_at / finished_at are unix seconds (i64), not milliseconds. - function fmtTs(unixSecs) { - if (!unixSecs) return ''; - const age = Math.floor(Date.now() / 1000) - unixSecs; - return fmtAgeSecs(Math.max(0, age)) + ' ago'; - } - - 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' }); - // status is null while running, 'ok'/'fail' when finished. - const live = !h.status; - const ok = h.status === 'ok'; - const statusClass = live ? 'badge badge-running' : ok ? 'badge badge-ok' : 'badge badge-fail'; - const statusLabel = live ? 'live' : ok ? 'ok' : 'fail'; - 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'); - let stdoutLen = 0, stderrLen = 0; - streamEs.onmessage = (e) => { - let frame; - try { frame = JSON.parse(e.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) { - badge.className = frame.status === 'ok' ? 'badge badge-ok' : 'badge badge-fail'; - badge.textContent = frame.status || 'done'; - streamEs.close(); streamEs = null; - detail.dataset.loaded = '1'; - } - }; - streamEs.onerror = () => { - if (streamEs && streamEs.readyState === EventSource.CONNECTING) { - pre.textContent = ''; stdoutLen = 0; stderrLen = 0; - } else { - badge.textContent = 'stream error'; - badge.className = 'badge badge-fail'; - if (streamEs) { 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 05cb5a9c..2a6229b7 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -841,6 +841,24 @@ 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); } @@ -1139,6 +1157,243 @@ 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 5f65e1ed..1492dbd6 100644
--- a/hive-c0re/src/build_logs.rs
+++ b/hive-c0re/src/build_logs.rs
@@ -318,25 +318,6 @@ 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 e65069f5..0d628817 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -63,11 +63,9 @@ 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))
@@ -1185,72 +1183,6 @@ 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,