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>
301 lines
16 KiB
JavaScript
301 lines
16 KiB
JavaScript
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|