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>
184 lines
7.2 KiB
JavaScript
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,
|
|
};
|