librelad 9f7ad8f177 feat(system): live 1 Hz SSE stream behind admin gauges + dashboard tile
Adds /api/system/stream — a Server-Sent Events feed driven by a single
per-process ticker that reads /proc directly and splices in the latest
host-side metrics.json each second. Subscribers share the connection so
N open tabs cost one ticker, and the ticker pauses entirely when nobody
is listening.

Frontend gets a singleton LiveSystem EventSource manager with auto-
reconnect, Page-Visibility integration (closes on tab hide), and last-
sample replay for late subscribers. Admin -> System gauges and the
dashboard memory + disk tile now tick at 1 Hz; trend charts and the
per-app table keep their 30 s poll because the underlying files only
regenerate once a minute.

Also adds /api/system/history as a thin range-query wrapper over the
existing 24 h JSON ring buffer — the binary ring backend will slot in
behind it in the next phase without changing the response shape.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 20:17:58 +01:00

246 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Admin → System — in-depth host + per-app statistics. Live ring gauges for the
// headline numbers, SVG trend charts driven by the metrics history ring buffer,
// a Docker summary, and a per-app resource table.
//
// Two data paths:
// - Live (1 Hz, via LiveSystem SSE): CPU%, memory, load, disks, network,
// docker totals. Updates the gauges in place each second so they tick like
// a real instrument.
// - Periodic (every 30 s, via fetch): the history ring buffer + per-app
// table. These regenerate at most once a minute on the host, so polling
// them faster would be wasted bandwidth.
// Renders into #config-section.
class AdminSystem {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.range = 60; // minutes of history to chart
this._timer = null;
this._unsubLive = null;
this.d = {};
}
root() { return document.getElementById(this.rootId); }
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
await this.refresh();
this.bind();
this._stopLive();
// 1 Hz live gauges via the shared EventSource manager.
if (window.LiveSystem) {
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
// History/per-app refresh stays slower — those files only regenerate
// once a minute on the host. Stop both paths once the user navigates off.
if (this._timer) clearInterval(this._timer);
this._timer = setInterval(() => {
if (!document.querySelector('.sys-page')) {
clearInterval(this._timer); this._timer = null;
this._stopLive();
return;
}
this.refresh();
}, 30000);
}
_stopLive() {
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
}
async refresh() {
const [metrics, history, apps, appsHist, info] = await Promise.all([
this.fetchJson('/data/system/metrics.json'),
this.fetchJson('/data/system/metrics_history.json'),
this.fetchJson('/data/system/metrics_apps.json'),
this.fetchJson('/data/system/metrics_apps_history.json'),
this.fetchJson('/data/system/system_info.json')
]);
this.d = { metrics, history, apps, appsHist, info };
this.render();
}
async fetchJson(url) {
try { const r = await fetch(`${url}?t=${Date.now()}`); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
bind() {
if (this._bound) return;
this._bound = true;
document.addEventListener('click', (e) => {
const rb = e.target.closest('[data-sys-range]');
if (rb && document.querySelector('.sys-page')) {
this.range = parseInt(rb.dataset.sysRange) || 60;
this.render();
}
});
}
/* ---- formatting helpers ---- */
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
bytes(n) {
n = Number(n) || 0;
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
rate(n) { return `${this.bytes(n)}/s`; }
// Last N minutes of a given key from the history ring buffer.
series(key) {
const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : [];
return pts.slice(-this.range).map(p => Number(p[key]) || 0);
}
rangeBtns() {
const opts = [[60, '1h'], [360, '6h'], [1440, '24h']];
return `<div class="sys-range">${opts.map(([m, l]) =>
`<button type="button" class="sys-range-btn ${this.range === m ? 'active' : ''}" data-sys-range="${m}">${l}</button>`
).join('')}</div>`;
}
chartCard(title, bodyHtml, meta) {
return `<div class="sys-chart-card">
<div class="sys-chart-head"><span>${title}</span>${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}</div>
<div class="sys-chart-body">${bodyHtml}</div>
</div>`;
}
// Shared by full render() and the 1 Hz live path so both produce identical
// gauge markup; only `this.d.metrics` differs in source.
_gaugesHtml() {
const C = window.LPCharts;
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
return `
${C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` })}
${C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` })}
${C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' })}
${C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''}` })}`;
}
// Fold a live SSE sample into this.d.metrics and refresh the in-page
// gauges + "updated" stamp without rebuilding the heavier sections.
// The payload shape matches the host generator's metrics.json so we can
// assign straight in; absent fields keep their previous value.
_applyLive(s) {
if (!s || !document.querySelector('.sys-page')) return;
const m = this.d.metrics || {};
this.d.metrics = {
...m,
cpu: s.cpu || m.cpu,
memory: s.memory || m.memory,
disks: Array.isArray(s.disks) && s.disks.length ? s.disks : m.disks,
network: s.network || m.network,
docker: s.docker || m.docker,
updated: new Date(s.t || Date.now()).toISOString()
};
const gaugesEl = document.querySelector('.sys-page .sys-gauges');
if (gaugesEl) gaugesEl.innerHTML = this._gaugesHtml();
const subEl = document.querySelector('.sys-page .page-header-title p');
if (subEl) subEl.textContent = `Live host and per-app statistics. Updated ${new Date(s.t || Date.now()).toLocaleTimeString()}.`;
}
render() {
const root = this.root();
if (!root) return;
const C = window.LPCharts;
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {}, net = m.network || {}, dk = m.docker || {};
const info = this.d.info || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
// Trend charts
const rx = this.series('net_rx'), tx = this.series('net_tx');
const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
const charts = `
<div class="sys-section-head">
<h2>Trends</h2>
${this.rangeBtns()}
</div>
<div class="sys-charts">
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %')}
${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %')}
${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/')}
${this.chartCard('Network',
C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) +
`<div class="sys-net-legend"><span><i class="dot ok"></i> ${this.rate(lastRx)}</span><span><i class="dot accent"></i> ${this.rate(lastTx)}</span></div>`,
'rx / tx')}
</div>`;
// Host info + swap + docker summary
const infoStrip = `
<div class="sys-strip">
${this.stat('OS', info.os || '—')}
${this.stat('Kernel', info.kernel || '—')}
${this.stat('Uptime', (info.uptime || '—').replace(/^up /, ''))}
${this.stat('CPU', info.cpu || '—')}
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
</div>`;
const dockerStrip = `
<div class="sys-section-head"><h2>Docker</h2></div>
<div class="sys-strip">
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
${this.stat('Images', String(dk.images ?? 0))}
${this.stat('Volumes', String(dk.volumes ?? 0))}
${this.stat('Mounts', String(disks.length))}
</div>`;
// Per-app table
const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : [];
const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {};
const appsBody = apps.length ? apps.map(a => {
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
const statusCls = a.status === 'running' ? 'ok' : 'none';
return `<tr>
<td class="sys-app-name"><span class="admin-status-dot ${statusCls}"></span>${this.escape(a.app)}
<span class="sys-app-sub">${a.running}/${a.containers} up</span></td>
<td>${C.bar(a.cpu_percent)}<span class="sys-cell-val">${(a.cpu_percent || 0).toFixed(1)}%</span></td>
<td>${C.bar(a.mem_percent)}<span class="sys-cell-val">${this.bytes(a.mem_bytes)}</span></td>
<td class="sys-net-cell">↓${this.bytes(a.net_rx)}${this.bytes(a.net_tx)}</td>
<td class="sys-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
</tr>`;
}).join('') : `<tr><td colspan="5" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
const appsTable = `
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">sorted by CPU</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th></tr></thead>
<tbody>${appsBody}</tbody>
</table>
</div>`;
const updated = m.updated ? new Date(m.updated).toLocaleTimeString() : '—';
root.innerHTML = `
<div class="admin-page sys-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>System</h1>
<p>Live host and per-app statistics. Updated ${updated}.</p>
</div>
</div>
${gauges}
${charts}
${infoStrip}
${dockerStrip}
${appsTable}
</div>`;
}
stat(label, value) {
return `<div class="sys-stat"><span class="sys-stat-label">${this.escape(label)}</span><strong class="sys-stat-value">${this.escape(value)}</strong></div>`;
}
}
window.AdminSystem = AdminSystem;