// /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(); })();