// panes/dashboard.js — control center: KPIs, service health with latency // sparklines, recent items, live MCP event ticker, quick actions. import { registerPane } from '../app.js'; import { jget, services, probeService, formatUptime, escapeHtml, mcp } from '../lib/api.js'; import { sparkline, meterRow, fmtBytes, fmtNum, ok, err, copyToClipboard } from '../lib/ui.js'; const TPL = `
Control center
Live status across every CxWebApp service.

Services

Resources

Probe RTT (last 30 sweeps)

Recent items

view all →

Quick actions

MCP event ticker

Activity log

stream
[boot] dashboard ready\n
`; function kpi(label, value, sub, accent = '★') { return `
${escapeHtml(label)}
${escapeHtml(value)}
${escapeHtml(sub || '')}
${accent}
`; } function svcRow(svc, res, history) { const cls = res.ok ? 'ok' : 'err'; const text = res.ok ? `up · ${res.ms}ms` : `down · ${escapeHtml(String(res.error))}`; return `
${escapeHtml(svc.label)}
${escapeHtml(svc.health)}
${sparkline(history, { w: 120, h: 22 })}
${text}
`; } function log(host, msg) { const pre = host.querySelector('#db-log'); if (!pre) return; const ts = new Date().toISOString().slice(11, 19); pre.textContent = `[${ts}] ${msg}\n` + pre.textContent; if (pre.textContent.length > 8000) pre.textContent = pre.textContent.slice(0, 8000); } const rttHist = {}; // service id -> [last 30 rtts] let lastSnapshot = {}; function recordRtt(id, ms) { if (!rttHist[id]) rttHist[id] = []; rttHist[id].push(ms); while (rttHist[id].length > 30) rttHist[id].shift(); } async function refresh(host) { const results = await Promise.all(services.map(s => probeService(s).then(r => ({ s, r })))); results.forEach(({ s, r }) => recordRtt(s.id, r.ms)); host.querySelector('#db-services').innerHTML = results.map(({ s, r }) => svcRow(s, r, rttHist[s.id])).join(''); const up = results.filter(x => x.r.ok).length; host.querySelector('#db-svc-meta').textContent = `${up}/${results.length} up`; let sys = {}, ver = {}, itemsResp = { items: [] }; try { [sys, ver, itemsResp] = await Promise.all([ jget('/api/system'), jget('/api/version'), jget('/api/items'), ]); } catch (_) {} host.querySelector('#db-kpis').innerHTML = [ kpi('Health', up === results.length ? 'All up' : `${up}/${results.length}`, up === results.length ? 'all systems normal' : 'some degraded', up === results.length ? '✓' : '!'), kpi('Uptime', formatUptime(sys.uptime_seconds), 'since process boot', '⏱'), kpi('Version', ver.version || '—', (ver.git_sha || '').slice(0, 7) || '—', '⌥'), kpi('Items', fmtNum(itemsResp.count ?? (itemsResp.items || []).length), 'stored in API', '☷'), ].join(''); // resource gauges const cpus = sys.cpu_count || 1; const load = +sys.loadavg?.[0] || 0; const memPct = +sys.mem_used_pct || 0; const loadPct = Math.min(100, load / cpus * 100); host.querySelector('#db-gauges').innerHTML = [ meterRow('Memory', memPct, `${fmtBytes(sys.rss_bytes)} / ${fmtBytes(sys.mem_total)}`, memPct > 85 ? 'err' : memPct > 60 ? 'warn' : 'ok'), meterRow('Load 1m', loadPct, load.toFixed(2), loadPct > 80 ? 'err' : loadPct > 50 ? 'warn' : 'ok'), ].join(''); // combined RTT sparkline (averaged across services) const len = Math.max(0, ...Object.values(rttHist).map(a => a.length)); const avg = Array.from({ length: len }, (_, i) => { const vals = Object.values(rttHist).map(a => a[i]).filter(v => Number.isFinite(v)); return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; }); host.querySelector('#db-spark').innerHTML = sparkline(avg, { w: 480, h: 36 }); host.querySelector('#db-res-ts').textContent = new Date().toLocaleTimeString(); // recent items const items = (itemsResp.items || []).slice(0, 6); host.querySelector('#db-items').innerHTML = items.length ? items.map(it => `
#${it.id}
${escapeHtml(it.name)}
${escapeHtml(it.description || '')}
`).join('') : '
No items yet.
'; // MCP events ticker try { const arr = await mcp.events(20); host.querySelector('#db-events').textContent = (arr || []).slice(-20).reverse().map(ev => { const t = ev.ts ?? ev.timestamp ?? ev.time; const time = t ? new Date(typeof t === 'number' ? (t < 1e12 ? t * 1000 : t) : t).toLocaleTimeString() : '—'; return `${time} ${ev.kind || ev.type || 'event'} ${typeof ev.detail === 'string' ? ev.detail : JSON.stringify(ev.detail ?? ev.data ?? '')}`; }).join('\n') || '(no events)'; } catch (e) { host.querySelector('#db-events').textContent = `(events unavailable: ${e.message})`; } lastSnapshot = { sys, ver, services: results.map(({ s, r }) => ({ id: s.id, ok: r.ok, ms: r.ms })) }; log(host, `refreshed · ${up}/${results.length} services up`); } let timer = null; registerPane('dashboard', { label: 'Dashboard', init(host) { host.innerHTML = TPL; host.querySelector('#db-refresh').addEventListener('click', () => refresh(host)); host.querySelector('#db-copy').addEventListener('click', () => copyToClipboard(JSON.stringify(lastSnapshot, null, 2), 'snapshot copied')); refresh(host); timer = setInterval(() => { if (!host.classList.contains('active')) return; if (!host.querySelector('#db-auto')?.checked) return; refresh(host); }, 15_000); }, refresh, });