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

226 lines
9.1 KiB
JavaScript
Raw Permalink 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.

// Binary ring buffer for system metrics, on-disk.
//
// Two tiers feed the Admin → System trend charts:
// - 1m: 1440 points → 24 h at 1-minute resolution
// - 5m: 2016 points → 7 d at 5-minute resolution
//
// Why binary instead of JSON: JSON parse of a 2016-point array is enough work
// (~ms in the libreportal container) that the history endpoint felt sluggish
// at 7-day ranges. A fixed 32-byte-per-point binary file is ~10× smaller, has
// O(range) cost with no parse, and the file is mmap-friendly if we ever need
// even larger windows.
//
// On-disk layout (little-endian):
// HEADER (32 B)
// 0 4 magic "LPMR"
// 4 1 version 0x01
// 5 1 point_size bytes per point (32)
// 6 1 field_count metric fields per point, not counting timestamp (7)
// 7 1 flags reserved
// 8 4 capacity max points
// 12 4 head next write index (0..capacity-1)
// 16 4 count valid points (<= capacity)
// 20 4 bucket_sec seconds per bucket (60 | 300)
// 24 4 last_t last bucket timestamp, unix seconds
// 28 4 reserved
//
// POINT (32 B)
// 0 4 uint32 t bucket start, unix seconds
// 4 4 float32 cpu %
// 8 4 float32 mem %
// 12 4 float32 swap %
// 16 4 float32 disk % (root mount)
// 20 4 float32 load1
// 24 4 float32 net_rx bytes/sec average over bucket
// 28 4 float32 net_tx bytes/sec
//
// File size: 32 + capacity * 32. For our tiers, 46 KB and 64 KB respectively.
// All writes are append-only (no mid-ring rewrites) and atomic at the byte-
// range level: we open with O_RDWR, write a single 32-byte point at the slot
// offset, then patch the header. A torn write is recoverable (count won't
// have advanced; the slot is just garbage that'll be overwritten next tick).
const fs = require('fs');
const path = require('path');
const MAGIC = Buffer.from('LPMR', 'ascii');
const VERSION = 0x01;
const POINT_SIZE = 32;
const HEADER_SIZE = 32;
const FIELD_COUNT = 7;
const FIELDS = ['cpu', 'mem', 'swap', 'disk', 'load1', 'net_rx', 'net_tx'];
// Build a new header buffer for `capacity` points at `bucketSec` resolution.
function newHeader(capacity, bucketSec) {
const h = Buffer.alloc(HEADER_SIZE);
MAGIC.copy(h, 0);
h.writeUInt8(VERSION, 4);
h.writeUInt8(POINT_SIZE, 5);
h.writeUInt8(FIELD_COUNT, 6);
h.writeUInt8(0, 7);
h.writeUInt32LE(capacity, 8);
h.writeUInt32LE(0, 12); // head
h.writeUInt32LE(0, 16); // count
h.writeUInt32LE(bucketSec, 20);
h.writeUInt32LE(0, 24); // last_t
h.writeUInt32LE(0, 28);
return h;
}
function parseHeader(buf) {
if (buf.length < HEADER_SIZE) throw new Error('ring: short header');
if (buf.slice(0, 4).compare(MAGIC) !== 0) throw new Error('ring: bad magic');
if (buf.readUInt8(4) !== VERSION) throw new Error('ring: unsupported version');
if (buf.readUInt8(5) !== POINT_SIZE) throw new Error('ring: wrong point size');
return {
version: buf.readUInt8(4),
pointSize: buf.readUInt8(5),
fieldCount: buf.readUInt8(6),
capacity: buf.readUInt32LE(8),
head: buf.readUInt32LE(12),
count: buf.readUInt32LE(16),
bucketSec: buf.readUInt32LE(20),
lastT: buf.readUInt32LE(24),
};
}
function encodePoint(p) {
const b = Buffer.alloc(POINT_SIZE);
b.writeUInt32LE(Math.max(0, Math.floor(p.t || 0)), 0);
b.writeFloatLE(Number(p.cpu) || 0, 4);
b.writeFloatLE(Number(p.mem) || 0, 8);
b.writeFloatLE(Number(p.swap) || 0, 12);
b.writeFloatLE(Number(p.disk) || 0, 16);
b.writeFloatLE(Number(p.load1) || 0, 20);
b.writeFloatLE(Number(p.net_rx) || 0, 24);
b.writeFloatLE(Number(p.net_tx) || 0, 28);
return b;
}
function decodePoint(buf, offset = 0) {
return {
t: buf.readUInt32LE(offset),
cpu: +buf.readFloatLE(offset + 4).toFixed(2),
mem: +buf.readFloatLE(offset + 8).toFixed(2),
swap: +buf.readFloatLE(offset + 12).toFixed(2),
disk: +buf.readFloatLE(offset + 16).toFixed(2),
load1: +buf.readFloatLE(offset + 20).toFixed(3),
net_rx: Math.round(buf.readFloatLE(offset + 24)),
net_tx: Math.round(buf.readFloatLE(offset + 28)),
};
}
class MetricsRing {
// file: absolute path to the .bin
// capacity: max points
// bucketSec: seconds per bucket (60 | 300)
constructor({ file, capacity, bucketSec }) {
this.file = file;
this.capacity = capacity;
this.bucketSec = bucketSec;
this.fd = null;
this.header = null;
}
// Open (creating if missing) and validate. Safe to call repeatedly.
async open() {
if (this.fd !== null) return;
const dir = path.dirname(this.file);
try { await fs.promises.mkdir(dir, { recursive: true }); } catch (_) {}
let needInit = false;
try {
const st = await fs.promises.stat(this.file);
const expected = HEADER_SIZE + this.capacity * POINT_SIZE;
if (st.size !== expected) needInit = true;
} catch (_) {
needInit = true;
}
if (needInit) {
const full = Buffer.alloc(HEADER_SIZE + this.capacity * POINT_SIZE);
newHeader(this.capacity, this.bucketSec).copy(full, 0);
await fs.promises.writeFile(this.file, full);
}
this.fd = await fs.promises.open(this.file, 'r+');
const headBuf = Buffer.alloc(HEADER_SIZE);
await this.fd.read(headBuf, 0, HEADER_SIZE, 0);
this.header = parseHeader(headBuf);
if (this.header.capacity !== this.capacity || this.header.bucketSec !== this.bucketSec) {
// Capacity / bucket changed (config bump) — start fresh. Old data
// wouldn't line up with the new grid anyway.
await this.fd.close();
this.fd = null;
await fs.promises.unlink(this.file);
return this.open();
}
}
async close() {
if (this.fd) { try { await this.fd.close(); } catch (_) {} this.fd = null; }
}
// Append a single point. Caller is responsible for rounding `t` to the
// bucket boundary (e.g. Math.floor(now / bucketSec) * bucketSec).
async append(point) {
await this.open();
const slot = this.header.head;
const offset = HEADER_SIZE + slot * POINT_SIZE;
const buf = encodePoint(point);
await this.fd.write(buf, 0, POINT_SIZE, offset);
this.header.head = (slot + 1) % this.capacity;
this.header.count = Math.min(this.capacity, this.header.count + 1);
this.header.lastT = point.t >>> 0;
const h = Buffer.alloc(HEADER_SIZE);
// Re-encode only the mutable fields; the static prefix stays the same.
MAGIC.copy(h, 0);
h.writeUInt8(VERSION, 4);
h.writeUInt8(POINT_SIZE, 5);
h.writeUInt8(FIELD_COUNT, 6);
h.writeUInt8(0, 7);
h.writeUInt32LE(this.capacity, 8);
h.writeUInt32LE(this.header.head, 12);
h.writeUInt32LE(this.header.count, 16);
h.writeUInt32LE(this.bucketSec, 20);
h.writeUInt32LE(this.header.lastT, 24);
h.writeUInt32LE(0, 28);
await this.fd.write(h, 0, HEADER_SIZE, 0);
// fdatasync would be safer against power loss but doubles cost; the
// worst case here is losing the most recent minute or two of metrics
// — not worth the IO penalty on every append.
}
// Read the last `n` points in chronological order (oldest → newest).
// Returns [] if the ring is empty.
async readLast(n) {
await this.open();
const { head, count, capacity } = this.header;
if (count === 0) return [];
const want = Math.max(1, Math.min(n | 0, count));
// The oldest of `want` lives `want` slots before head (modulo cap).
const start = (head - want + capacity) % capacity;
// Two-segment read so we don't span the wrap unnecessarily.
const out = new Array(want);
if (start + want <= capacity) {
const buf = Buffer.alloc(want * POINT_SIZE);
await this.fd.read(buf, 0, buf.length, HEADER_SIZE + start * POINT_SIZE);
for (let i = 0; i < want; i++) out[i] = decodePoint(buf, i * POINT_SIZE);
} else {
const firstLen = capacity - start;
const a = Buffer.alloc(firstLen * POINT_SIZE);
const b = Buffer.alloc((want - firstLen) * POINT_SIZE);
await this.fd.read(a, 0, a.length, HEADER_SIZE + start * POINT_SIZE);
await this.fd.read(b, 0, b.length, HEADER_SIZE);
for (let i = 0; i < firstLen; i++) out[i] = decodePoint(a, i * POINT_SIZE);
for (let i = 0; i < want - firstLen; i++) out[firstLen + i] = decodePoint(b, i * POINT_SIZE);
}
return out;
}
// Convenience: latest bucket timestamp on disk (0 if empty).
async lastT() {
await this.open();
return this.header.lastT || 0;
}
}
module.exports = { MetricsRing, FIELDS, HEADER_SIZE, POINT_SIZE };