// Logs — live tail with filters, severity colors, JSON detail expanders. // Wired to GET /api/v1/logs (polling). Query params: limit, level, kind. // Backend LogEntry shape: {time, level, message, attrs:{kind?, account?, …}}. const { Card, Pill, fmtTs } = window.UI; // adapt one backend LogEntry to the Mock-shape this page already renders. function adaptEntry(e) { const attrs = e.attrs || {}; const kind = attrs.kind != null ? String(attrs.kind) : ''; const account = attrs.account != null ? String(attrs.account) : ''; // Strip well-known fields out of the data blob shown in the expander. const data = {}; for (const [k, v] of Object.entries(attrs)) { if (k === 'kind' || k === 'account') continue; data[k] = v; } const tsMs = e.time ? Date.parse(e.time) : Date.now(); const sev = (e.level || 'info').toLowerCase(); return { ts: tsMs, sev: sev === 'warning' ? 'warn' : sev, kind, msg: e.message || '', account, data, }; } function PageLogs({ app }) { const [logs, setLogs] = React.useState([]); const [tail, setTail] = React.useState(true); const [filters, setFilters] = React.useState({ sev: 'all', kind: '', account: 'all' }); const [expanded, setExpanded] = React.useState(new Set()); const [error, setError] = React.useState(''); const scrollerRef = React.useRef(); // Poll /api/v1/logs every 2s when tailing; once on mount otherwise. React.useEffect(() => { let stopped = false; const fetchOnce = async () => { try { const params = { limit: 500 }; if (filters.sev !== 'all') params.level = filters.sev; // server-side filter by attr kind only when text matches a known // bucket; otherwise leave kind blank and filter client-side. const raw = await window.API.get('/api/v1/logs', params); if (stopped) return; const adapted = (Array.isArray(raw) ? raw : []).map(adaptEntry); // Newest-last for the tail UX. adapted.reverse(); setLogs(adapted); setError(''); } catch (e) { if (!stopped) setError(e.message || 'logs fetch failed'); } }; fetchOnce(); if (!tail) return () => { stopped = true; }; const id = setInterval(fetchOnce, 2000); return () => { stopped = true; clearInterval(id); }; }, [tail, filters.sev]); React.useEffect(() => { if (tail && scrollerRef.current) { scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; } }, [logs, tail]); // Account list comes from app state — empty until the boot fetch // hydrates. The dropdown shows only configured accounts. const accountOptions = React.useMemo(() => { return ((app && app.accounts) || []).map(a => a.id); }, [app && app.accounts]); const filtered = logs.filter(l => { // Severity is enforced server-side too, but client-side filter still // narrows when the server already returned mixed levels. if (filters.sev !== 'all' && l.sev !== filters.sev) return false; if (filters.kind) { const q = filters.kind.toLowerCase(); if (!(l.kind || '').toLowerCase().includes(q) && !(l.msg || '').toLowerCase().includes(q)) { return false; } } if (filters.account !== 'all' && l.account !== filters.account) return false; return true; }); const sevColor = { debug: 'text-ink-3', info: 'text-inf-0', warn: 'text-amb-0', error: 'text-neg-0' }; const sevBg = { debug: 'bg-ink-3', info: 'bg-inf-0', warn: 'bg-amb-0', error: 'bg-neg-0' }; const toggle = (i) => setExpanded(s => { const c = new Set(s); c.has(i) ? c.delete(i) : c.add(i); return c; }); return (

logs

structured JSON · last 500 lines · {filtered.length} match

{error &&
{error}
}
setFilters(f => ({...f, kind: e.target.value}))} placeholder="filter by kind or message…" className="w-full bg-bg-2 border border-line-1 mono text-[11.5px] h-7 pl-7 pr-2 text-ink-1 outline-none focus:border-line-2"/>
{filtered.length === 0 && (
no log entries match · {tail ? 'waiting for new lines…' : 'tail paused'}
)} {filtered.map((l, i) => { const last = tail && i === filtered.length - 1; const isOpen = expanded.has(i); return (
{isOpen && (
{JSON.stringify({ ts: l.ts, sev: l.sev, kind: l.kind, account: l.account, msg: l.msg, ...l.data }, null, 2)}
                  
)}
); })}
); } window.PageLogs = PageLogs;