librelad 6346d76a92 feat(system): binary ring history with 7-day retention + fullscreen detail UI
Replaces the JSON history file behind /api/system/history with a fixed-size
binary ring buffer on disk and adds a second, downsampled tier so the chart
can now span seven days, not just twenty-four hours.

Two on-disk rings under frontend/data/system/:
  metrics_ring_1m.bin  1440 pts @ 1 min  ( 24 h)
  metrics_ring_5m.bin  2016 pts @ 5 min  (  7 d)

Each point is 32 bytes (uint32 timestamp + 7 float32 metrics — cpu / mem /
swap / disk / load1 / net_rx / net_tx); files carry a 32-byte header with
magic, version, capacity, head, count, bucket seconds, and last bucket time
so they're self-describing and torn-write recoverable.

A persistent 1-minute ticker inside the backend (independent of whether
anyone's subscribed to /api/system/stream) composes points from /proc plus
the bash generator's latest snapshots and appends to the 1m ring; every
five minutes it averages the last five 1m points into the 5m ring. On
first run, the writer backfills the 1m ring from the legacy
metrics_history.json so first paint already has 24 h.

/api/system/history?range=N auto-selects the tier (≤1440 → 1m, else 5m),
keeps the existing { points, updated } shape, and additionally returns
`tier` for clients that care. Falls back to the legacy JSON on cold start.

Admin → System: 7d added to the range picker (now 1h / 6h / 24h / 7d),
swap + load1 promoted to their own trend cards, and every gauge / chart
card grows an Expand affordance that opens a fullscreen single-metric
deep-dive overlay:
  - Big themed chart with grid, gradient area, peak/min/now markers, and
    a live-pulsing "now" dot
  - Hover crosshair + tooltip scrubs the series with formatted time +
    value
  - now / peak / avg / min stat strip with deltas
  - Range picker (1h / 6h / 24h / 7d) re-fetches and re-themes per metric
  - 1 Hz live SSE feed updates the overlay's now-stat in real time
  - Escape / backdrop / close button all dismiss
  - Per-metric accent colour (cpu=accent, mem=info, disk/swap=warning,
    net_rx=success, net_tx=accent, load=accent) flows through gradient,
    border, dot, and stats card

Zero new dependencies — hand-rolled SVG and pointer events throughout.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 21:04:27 +01:00

301 lines
16 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() {
// History now comes from /api/system/history (binary ring backed,
// supports up to 7 days). Everything else stays on the static JSONs
// the host generator writes.
const [metrics, history, apps, appsHist, info] = await Promise.all([
this.fetchJson('/data/system/metrics.json'),
this.fetchJson(`/api/system/history?range=${this.range}`),
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) => {
if (!document.querySelector('.sys-page')) return;
const rb = e.target.closest('[data-sys-range]');
if (rb) {
this.range = parseInt(rb.dataset.sysRange) || 60;
// 7d needs a server hit because we don't keep that locally.
this.refresh();
return;
}
// Expand any metric surface — gauges, chart cards, table rows.
const ex = e.target.closest('[data-sys-expand]');
if (ex && window.systemDetail) {
const k = ex.dataset.sysExpand;
const def = this._metricDefs()[k];
if (def) window.systemDetail.open(def);
}
});
}
// Metric definitions consumed by the fullscreen detail overlay. Each maps
// the history-ring key to its labels, units, formatter, and accent color.
_metricDefs() {
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const root = disks.find(d => d.mount === '/') || disks[0] || {};
const pctFmt = (v) => `${v.toFixed(1)}%`;
const rateFmt = (v) => this.rate(v);
const loadFmt = (v) => v.toFixed(2);
// Pull the literal RGB triplet for each metric so the overlay can
// theme its gradient + accents per metric. Falls back to --accent-rgb.
const rgbVar = (name) =>
(getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
return {
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${cpu.cores || '?'} cores`, accentRgb: rgbVar('accent') },
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}`, accentRgb: rgbVar('status-info') },
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pctFmt, sublabel: mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'no swap', accentRgb: rgbVar('status-warning') },
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pctFmt, sublabel: root.mount || '/', accentRgb: rgbVar('status-warning') },
load1: { key: 'load1', label: 'Load average', fmt: loadFmt, sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''} (${cpu.cores || '?'} cores)`, accentRgb: rgbVar('accent') },
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('status-success') },
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('accent') },
};
}
/* ---- 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'], [10080, '7d']];
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, expandKey) {
const exBtn = expandKey
? `<button type="button" class="sys-expand" data-sys-expand="${expandKey}" aria-label="Expand ${title}">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>` : '';
return `<div class="sys-chart-card">
<div class="sys-chart-head"><span>${title}</span><span class="sys-chart-head-right">${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}${exBtn}</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.
// Each gauge is wrapped in a button surface (data-sys-expand=<key>) so a
// click anywhere on the gauge opens the fullscreen detail overlay for
// that metric.
_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] || {};
const wrap = (key, inner) =>
`<button type="button" class="sys-gauge-wrap" data-sys-expand="${key}" aria-label="Expand ${key}">
${inner}
<span class="sys-gauge-expand">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>`;
return `
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
${wrap('load1', 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 hasSwap = (mem.swap_total || 0) > 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 %', 'cpu')}
${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'mem')}
${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/', 'disk')}
${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', 'net_rx')}
${this.chartCard('Load (1m)', C.areaChart(this.series('load1'), { color: 'accent', fmt: v => v.toFixed(2) }), `${cpu.cores || '?'} cores`, 'load1')}
${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
</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;