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

184 lines
7.2 KiB
JavaScript

// Persistent metrics-history writer.
//
// Runs alongside the SSE ticker inside the libreportal container. Every
// minute, on the bucket boundary, it composes a single sample from /proc plus
// the latest host-side JSON snapshots and appends it to the 1-minute ring;
// every 5 minutes it also pushes a (5-pt average) point into the 5-minute
// ring. Independent of whether any client is subscribed to /api/system/stream
// — the trend charts must keep filling even when nobody's watching.
//
// On startup, if the 1-minute ring is empty but the legacy metrics_history.
// json exists, we backfill from it so first paint already has 24 h of data.
const fs = require('fs').promises;
const path = require('path');
const { MetricsRing } = require('./metrics-ring.js');
const ONE_MIN = 60;
const FIVE_MIN = 300;
const ONE_MIN_CAP = 1440; // 24 h
const FIVE_MIN_CAP = 2016; // 7 d
const HOST_JSON_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'system');
const RING_1M = path.join(HOST_JSON_DIR, 'metrics_ring_1m.bin');
const RING_5M = path.join(HOST_JSON_DIR, 'metrics_ring_5m.bin');
const LEGACY_HIST = path.join(HOST_JSON_DIR, 'metrics_history.json');
const ring1 = new MetricsRing({ file: RING_1M, capacity: ONE_MIN_CAP, bucketSec: ONE_MIN });
const ring5 = new MetricsRing({ file: RING_5M, capacity: FIVE_MIN_CAP, bucketSec: FIVE_MIN });
// readSampleFn is injected so we don't have a circular require with system-
// routes.js (which also wants to use sample()).
let readSample = null;
let readHostJson = null;
let tickHandle = null;
let started = false;
// Floor a unix-seconds timestamp to the given bucket size.
const floorBucket = (t, sec) => Math.floor(t / sec) * sec;
async function safeJson(file) {
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch (_) { return null; }
}
// Build a metrics point from a live /proc sample + the latest host JSON.
async function composePoint(t) {
const live = await readSample();
const hostMetrics = await safeJson(path.join(HOST_JSON_DIR, 'metrics.json'));
const disks = Array.isArray(hostMetrics?.disks) ? hostMetrics.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
const net = hostMetrics?.network || {};
return {
t,
cpu: Number(live?.cpu?.percent) || 0,
mem: Number(live?.memory?.percent) || 0,
swap: Number(live?.memory?.swap_percent) || 0,
disk: Number(rootDisk.percent) || 0,
load1: Number(live?.cpu?.load1) || 0,
net_rx: Number(net.rx_rate) || 0,
net_tx: Number(net.tx_rate) || 0,
};
}
// Average the latest 5 1-min points into one 5-min bucket. Keeps the same
// shape as composePoint() so it slots straight into ring5.append.
function averagePoints(pts, t) {
if (!pts.length) return null;
const sum = { cpu: 0, mem: 0, swap: 0, disk: 0, load1: 0, net_rx: 0, net_tx: 0 };
for (const p of pts) for (const k of Object.keys(sum)) sum[k] += Number(p[k]) || 0;
const n = pts.length;
return {
t,
cpu: sum.cpu / n,
mem: sum.mem / n,
swap: sum.swap / n,
disk: sum.disk / n,
load1: sum.load1 / n,
net_rx: sum.net_rx / n,
net_tx: sum.net_tx / n,
};
}
// Backfill the 1-min ring from the legacy JSON if and only if the ring is
// empty. Idempotent; safe to call on every startup.
async function backfillFromLegacy() {
await ring1.open();
if ((await ring1.lastT()) > 0) return false;
const j = await safeJson(LEGACY_HIST);
const pts = Array.isArray(j?.points) ? j.points : [];
if (!pts.length) return false;
let last = 0;
let appended = 0;
for (const p of pts) {
const t = floorBucket(Number(p.t) || 0, ONE_MIN);
if (t <= last) continue; // points must advance monotonically
last = t;
await ring1.append({
t,
cpu: Number(p.cpu) || 0,
mem: Number(p.mem) || 0,
swap: Number(p.swap) || 0,
disk: Number(p.disk) || 0,
load1: Number(p.load1) || 0,
net_rx: Number(p.net_rx) || 0,
net_tx: Number(p.net_tx) || 0,
});
appended++;
}
return appended;
}
// Read one minute / five minute slice in the format the API returns.
async function read(rangeMin, tier) {
const r = tier === '5m' ? ring5 : ring1;
const pts = await r.readLast(rangeMin);
return pts;
}
// Single tick. Fires once per minute (give or take a few ms drift) and writes
// at most one 1m point + optionally one 5m point. Idempotent within a bucket
// — if the ring's last_t already matches the bucket we're about to write,
// skip.
async function tick() {
try {
const now = Math.floor(Date.now() / 1000);
const bucket1 = floorBucket(now, ONE_MIN);
const last1 = await ring1.lastT();
if (bucket1 <= last1) return; // already wrote this minute
const point = await composePoint(bucket1);
await ring1.append(point);
const bucket5 = floorBucket(now, FIVE_MIN);
const last5 = await ring5.lastT();
if (bucket5 > last5 && (now - bucket5) < ONE_MIN * 2) {
// We've just crossed a 5-min boundary; average the last 5 1-min
// points to form the 5-min point. Window the average to the
// 5-min bucket so a long run-up doesn't smear into the new one.
const recent = await ring1.readLast(5);
const inWindow = recent.filter(p => p.t >= bucket5 && p.t < bucket5 + FIVE_MIN);
const avgPts = inWindow.length ? inWindow : recent;
const avg = averagePoints(avgPts, bucket5);
if (avg) await ring5.append(avg);
}
} catch (err) {
// Swallow — a single failed tick mustn't kill the writer. The next
// boundary will retry. Log loudly enough to be findable but not so
// loudly that a missing JSON file spams the console.
if (process.env.METRICS_DEBUG) console.error('metrics-writer tick:', err.message);
}
}
// Public API. Pass in the read functions so we don't double-require system-
// routes.js (which owns the shared cpu/mem sampler).
function start({ sampleFn, hostJsonFn } = {}) {
if (started) return;
started = true;
readSample = sampleFn;
readHostJson = hostJsonFn;
// Defer the first real tick to the start of the next minute so the
// boundary is clean. In the meantime, kick a backfill in the background.
backfillFromLegacy().catch(() => {});
const align = () => {
const ms = Date.now();
const toNextMin = ONE_MIN * 1000 - (ms % (ONE_MIN * 1000));
setTimeout(() => {
tick();
tickHandle = setInterval(tick, ONE_MIN * 1000);
}, toNextMin + 200); // tiny offset so the host generator has finished its own bucket
};
align();
}
function stop() {
if (tickHandle) { clearInterval(tickHandle); tickHandle = null; }
started = false;
Promise.all([ring1.close(), ring5.close()]).catch(() => {});
}
module.exports = {
start, stop, read,
// exposed for tests / introspection
_ring1: ring1, _ring5: ring5,
ONE_MIN_CAP, FIVE_MIN_CAP,
};