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>
This commit is contained in:
parent
70415b0223
commit
6346d76a92
@ -22,6 +22,7 @@ const express = require('express');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const metricsWriter = require('../utils/metrics-writer.js');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -238,36 +239,61 @@ router.get('/stream', async (req, res) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// History range query
|
// History range query
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reads the 24 h ring buffer the host-side generator writes and returns a
|
// `range` is minutes back from now (1..10080 = 7 days). `keys` is an optional
|
||||||
// slice. `range` is minutes back from now (1..1440). `keys` is a comma-list of
|
// comma-list of metric names to project (defaults to the whole point).
|
||||||
// metric names to project (defaults to the whole point).
|
|
||||||
//
|
//
|
||||||
// This is a thin wrapper today — the buffer lives on disk as metrics_history.
|
// Two on-disk binary rings back this:
|
||||||
// json. A binary ring-buffer backend will slot in here in Phase 2 without
|
// 1m tier — 1440 pts @ 1-min (24 h)
|
||||||
// changing the response shape.
|
// 5m tier — 2016 pts @ 5-min ( 7 d)
|
||||||
|
//
|
||||||
|
// Tier auto-selects from `range`: ≤ 1440 reads the 1m ring point-for-point;
|
||||||
|
// > 1440 reads the 5m ring (range/5 points). Caller can override with
|
||||||
|
// `?tier=1m|5m`. Falls back to the legacy JSON only if the binary ring is
|
||||||
|
// completely empty (e.g. fresh container, writer hasn't filled it yet).
|
||||||
|
const HISTORY_MAX_MIN = 10080; // 7 days
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
const range = Math.max(1, Math.min(1440, parseInt(req.query.range, 10) || 60));
|
const range = Math.max(1, Math.min(HISTORY_MAX_MIN, parseInt(req.query.range, 10) || 60));
|
||||||
|
const tier = req.query.tier === '5m' || req.query.tier === '1m'
|
||||||
|
? req.query.tier
|
||||||
|
: (range > 1440 ? '5m' : '1m');
|
||||||
|
const wantPoints = tier === '5m' ? Math.ceil(range / 5) : range;
|
||||||
const keys = typeof req.query.keys === 'string' && req.query.keys.length
|
const keys = typeof req.query.keys === 'string' && req.query.keys.length
|
||||||
? req.query.keys.split(',').map((s) => s.trim()).filter(Boolean)
|
? req.query.keys.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
try {
|
try {
|
||||||
const file = path.join(HOST_JSON_DIR, 'metrics_history.json');
|
let pts = await metricsWriter.read(wantPoints, tier);
|
||||||
const raw = await fs.readFile(file, 'utf8');
|
let updated = null;
|
||||||
const parsed = JSON.parse(raw);
|
if (pts.length) {
|
||||||
const all = Array.isArray(parsed?.points) ? parsed.points : [];
|
updated = new Date(pts[pts.length - 1].t * 1000).toISOString();
|
||||||
const sliced = all.slice(-range);
|
} else {
|
||||||
|
// Cold start: writer hasn't filled the ring yet. Serve from the
|
||||||
|
// legacy JSON so the UI still has something to draw.
|
||||||
|
const file = path.join(HOST_JSON_DIR, 'metrics_history.json');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
pts = (Array.isArray(parsed?.points) ? parsed.points : []).slice(-wantPoints);
|
||||||
|
updated = parsed?.updated || null;
|
||||||
|
} catch (_) { /* leave pts empty */ }
|
||||||
|
}
|
||||||
const points = keys
|
const points = keys
|
||||||
? sliced.map((p) => {
|
? pts.map((p) => {
|
||||||
const out = { t: p.t };
|
const out = { t: p.t };
|
||||||
for (const k of keys) if (k in p) out[k] = p[k];
|
for (const k of keys) if (k in p) out[k] = p[k];
|
||||||
return out;
|
return out;
|
||||||
})
|
})
|
||||||
: sliced;
|
: pts;
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
res.json({ range, points, updated: parsed?.updated || null });
|
res.json({ range, tier, points, updated });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
res.status(404).json({ error: 'history_unavailable', points: [] });
|
res.status(500).json({ error: 'history_unavailable', points: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Kick the persistent 1-min writer. It needs the same `sample()` we use for
|
||||||
|
// the SSE stream — passed in to avoid a circular require.
|
||||||
|
metricsWriter.start({
|
||||||
|
sampleFn: sample,
|
||||||
|
hostJsonFn: () => hostJson,
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
225
containers/libreportal/backend/utils/metrics-ring.js
Normal file
225
containers/libreportal/backend/utils/metrics-ring.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// 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 };
|
||||||
183
containers/libreportal/backend/utils/metrics-writer.js
Normal file
183
containers/libreportal/backend/utils/metrics-writer.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
@ -178,7 +178,13 @@
|
|||||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px 10px;
|
padding: 14px 16px 10px;
|
||||||
|
transition: border-color .2s ease, box-shadow .2s ease;
|
||||||
}
|
}
|
||||||
|
.sys-chart-card:hover {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.28);
|
||||||
|
box-shadow: 0 6px 22px rgba(var(--accent-rgb), 0.10);
|
||||||
|
}
|
||||||
|
.sys-chart-card:hover .sys-expand { color: var(--accent); background: rgba(var(--accent-rgb), 0.18); }
|
||||||
.sys-chart-head {
|
.sys-chart-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@ -301,3 +307,314 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
|||||||
}
|
}
|
||||||
.lp-bar-fill { display: block; height: 100%; border-radius: 3px; transition: width .4s ease; }
|
.lp-bar-fill { display: block; height: 100%; border-radius: 3px; transition: width .4s ease; }
|
||||||
.lp-spark { width: 100px; height: 24px; display: block; }
|
.lp-spark { width: 100px; height: 24px; display: block; }
|
||||||
|
|
||||||
|
/* Gauge wrapper — each gauge tile is a button that opens the detail overlay.
|
||||||
|
The visual is identical to before (.lp-gauge); the wrapper adds the press
|
||||||
|
affordance + the corner expand icon. */
|
||||||
|
.sys-gauge-wrap {
|
||||||
|
all: unset;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
.sys-gauge-wrap:focus-visible { outline: 2px solid rgba(var(--accent-rgb), 0.6); outline-offset: 2px; }
|
||||||
|
.sys-gauge-wrap:hover { transform: translateY(-1px); }
|
||||||
|
.sys-gauge-wrap:hover .lp-gauge { box-shadow: 0 6px 24px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.35); }
|
||||||
|
.sys-gauge-wrap .lp-gauge { transition: box-shadow .2s ease, border-color .2s ease; height: 100%; }
|
||||||
|
.sys-gauge-expand {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px; right: 10px;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(var(--text-rgb), 0.04);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s ease, color .15s ease, background .15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sys-gauge-wrap:hover .sys-gauge-expand,
|
||||||
|
.sys-gauge-wrap:focus-visible .sys-gauge-expand { opacity: 1; }
|
||||||
|
.sys-gauge-wrap:hover .sys-gauge-expand { color: var(--accent); background: rgba(var(--accent-rgb), 0.12); }
|
||||||
|
|
||||||
|
/* Chart card head — right cluster keeps the meta + expand button aligned. */
|
||||||
|
.sys-chart-head { gap: 12px; }
|
||||||
|
.sys-chart-head-right { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.sys-expand {
|
||||||
|
all: unset;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
color: rgba(var(--text-rgb), 0.5);
|
||||||
|
background: rgba(var(--text-rgb), 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s ease, background .15s ease, transform .15s ease;
|
||||||
|
}
|
||||||
|
.sys-expand:hover { color: var(--accent); background: rgba(var(--accent-rgb), 0.14); transform: scale(1.06); }
|
||||||
|
.sys-expand:focus-visible { outline: 2px solid rgba(var(--accent-rgb), 0.6); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Admin → System — fullscreen single-metric deep-dive overlay (.sys-detail)
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
body.sys-detail-active { overflow: hidden; }
|
||||||
|
|
||||||
|
.sys-detail {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sys-detail.open { display: block; pointer-events: auto; }
|
||||||
|
.sys-detail-backdrop {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 50% at 50% 35%, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%),
|
||||||
|
rgba(0, 0, 0, 0.62);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
animation: sys-detail-fade .22s ease;
|
||||||
|
}
|
||||||
|
@keyframes sys-detail-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.sys-detail-panel {
|
||||||
|
--metric-rgb: var(--accent-rgb);
|
||||||
|
position: absolute;
|
||||||
|
inset: 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr auto;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 26px 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(80% 60% at 80% 0%, rgba(var(--metric-rgb), 0.10) 0%, transparent 60%),
|
||||||
|
linear-gradient(160deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.18) 100%),
|
||||||
|
var(--surface-bg-solid, #0f1729);
|
||||||
|
border: 1px solid rgba(var(--metric-rgb), 0.32);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
|
||||||
|
0 -1px 0 rgba(var(--metric-rgb), 0.4) inset;
|
||||||
|
animation: sys-detail-rise .28s cubic-bezier(.2,.7,.2,1.0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
@keyframes sys-detail-rise {
|
||||||
|
from { opacity: 0; transform: translateY(14px) scale(.985); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.sys-detail-eyebrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.sys-detail-name {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 110%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.sys-detail-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: rgba(var(--text-rgb), 0.6);
|
||||||
|
}
|
||||||
|
.sys-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.sys-detail-range { display: inline-flex; gap: 4px; padding: 4px; background: rgba(var(--text-rgb), 0.06); border-radius: 999px; }
|
||||||
|
.sys-detail-range-btn {
|
||||||
|
all: unset;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--text-rgb), 0.65);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.sys-detail-range-btn:hover { color: var(--text-primary); }
|
||||||
|
.sys-detail-range-btn.active {
|
||||||
|
color: var(--text-on-accent, #0a1426);
|
||||||
|
background: rgba(var(--metric-rgb), 0.95);
|
||||||
|
box-shadow: 0 2px 12px rgba(var(--metric-rgb), 0.35);
|
||||||
|
}
|
||||||
|
.sys-detail-close {
|
||||||
|
all: unset;
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgba(var(--text-rgb), 0.7);
|
||||||
|
background: rgba(var(--text-rgb), 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.sys-detail-close:hover { color: var(--text-primary); background: rgba(var(--text-rgb), 0.14); }
|
||||||
|
.sys-detail-close:focus-visible { outline: 2px solid rgba(var(--metric-rgb), 0.6); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.sys-detail-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.sys-detail-stat {
|
||||||
|
padding: 14px 18px 12px;
|
||||||
|
background: rgba(var(--text-rgb), 0.04);
|
||||||
|
border: 1px solid rgba(var(--text-rgb), 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color .2s ease;
|
||||||
|
}
|
||||||
|
.sys-detail-stat[data-stat="now"] {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--metric-rgb), 0.16) 0%, rgba(var(--metric-rgb), 0.05) 100%);
|
||||||
|
border-color: rgba(var(--metric-rgb), 0.35);
|
||||||
|
}
|
||||||
|
.sys-detail-stat-k {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: rgba(var(--text-rgb), 0.5);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sys-detail-stat-v {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.05;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.sys-detail-stat[data-stat="now"] .sys-detail-stat-v {
|
||||||
|
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.sys-detail-stat-t {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-detail-canvas {
|
||||||
|
position: relative;
|
||||||
|
min-height: 260px;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% 100%, rgba(var(--metric-rgb), 0.08) 0%, transparent 60%),
|
||||||
|
rgba(0, 0, 0, 0.22);
|
||||||
|
border: 1px solid rgba(var(--text-rgb), 0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sys-detail-svg { width: 100%; height: 100%; display: block; }
|
||||||
|
.sys-detail-loading,
|
||||||
|
.sys-detail-empty {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sys-detail-empty { color: rgba(var(--text-rgb), 0.55); }
|
||||||
|
|
||||||
|
.sys-detail-grid {
|
||||||
|
stroke: rgba(var(--text-rgb), 0.07);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2 4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.sys-detail-axis-line {
|
||||||
|
stroke: rgba(var(--text-rgb), 0.18);
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.sys-detail-axis {
|
||||||
|
fill: rgba(var(--text-rgb), 0.55);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.sys-detail-axis-y { text-anchor: end; }
|
||||||
|
.sys-detail-axis-x { text-anchor: middle; }
|
||||||
|
.sys-detail-peak { fill: var(--status-warning); filter: drop-shadow(0 0 6px rgba(var(--status-warning-rgb, 255 193 7), 0.6)); }
|
||||||
|
.sys-detail-min { fill: rgba(var(--text-rgb), 0.55); }
|
||||||
|
.sys-detail-now {
|
||||||
|
fill: rgb(var(--metric-rgb));
|
||||||
|
filter: drop-shadow(0 0 8px rgba(var(--metric-rgb), 0.7));
|
||||||
|
animation: sys-detail-pulse 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sys-detail-pulse {
|
||||||
|
0%, 100% { r: 5; }
|
||||||
|
50% { r: 7; }
|
||||||
|
}
|
||||||
|
.sys-detail-cross {
|
||||||
|
stroke: rgba(var(--metric-rgb), 0.55);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sys-detail-cross-dot {
|
||||||
|
fill: rgb(var(--metric-rgb));
|
||||||
|
stroke: var(--surface-bg-solid, #0f1729);
|
||||||
|
stroke-width: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-detail-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(15, 23, 41, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(var(--metric-rgb), 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: transform 60ms linear;
|
||||||
|
will-change: transform;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,0.4);
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
.sys-detail-tip-v { font-weight: 700; font-size: 0.95rem; font-variant-numeric: tabular-nums; color: rgb(var(--metric-rgb)); }
|
||||||
|
.sys-detail-tip-t { font-size: 0.72rem; color: rgba(var(--text-rgb), 0.6); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.sys-detail-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.sys-detail-foot-hint kbd,
|
||||||
|
.sys-detail-foot-hint { letter-spacing: 0.02em; }
|
||||||
|
|
||||||
|
/* Narrower viewports: collapse the stat grid to 2×2 and shrink padding. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sys-detail-panel { inset: 10px; padding: 16px 16px 12px; gap: 12px; }
|
||||||
|
.sys-detail-name { font-size: 1.3rem; }
|
||||||
|
.sys-detail-stats { grid-template-columns: 1fr 1fr; }
|
||||||
|
.sys-detail-stat-v { font-size: 1.25rem; }
|
||||||
|
.sys-detail-actions { flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||||
|
}
|
||||||
|
|||||||
@ -49,9 +49,12 @@ class AdminSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
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([
|
const [metrics, history, apps, appsHist, info] = await Promise.all([
|
||||||
this.fetchJson('/data/system/metrics.json'),
|
this.fetchJson('/data/system/metrics.json'),
|
||||||
this.fetchJson('/data/system/metrics_history.json'),
|
this.fetchJson(`/api/system/history?range=${this.range}`),
|
||||||
this.fetchJson('/data/system/metrics_apps.json'),
|
this.fetchJson('/data/system/metrics_apps.json'),
|
||||||
this.fetchJson('/data/system/metrics_apps_history.json'),
|
this.fetchJson('/data/system/metrics_apps_history.json'),
|
||||||
this.fetchJson('/data/system/system_info.json')
|
this.fetchJson('/data/system/system_info.json')
|
||||||
@ -69,14 +72,49 @@ class AdminSystem {
|
|||||||
if (this._bound) return;
|
if (this._bound) return;
|
||||||
this._bound = true;
|
this._bound = true;
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!document.querySelector('.sys-page')) return;
|
||||||
const rb = e.target.closest('[data-sys-range]');
|
const rb = e.target.closest('[data-sys-range]');
|
||||||
if (rb && document.querySelector('.sys-page')) {
|
if (rb) {
|
||||||
this.range = parseInt(rb.dataset.sysRange) || 60;
|
this.range = parseInt(rb.dataset.sysRange) || 60;
|
||||||
this.render();
|
// 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 ---- */
|
/* ---- formatting helpers ---- */
|
||||||
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
bytes(n) {
|
bytes(n) {
|
||||||
@ -94,32 +132,46 @@ class AdminSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rangeBtns() {
|
rangeBtns() {
|
||||||
const opts = [[60, '1h'], [360, '6h'], [1440, '24h']];
|
const opts = [[60, '1h'], [360, '6h'], [1440, '24h'], [10080, '7d']];
|
||||||
return `<div class="sys-range">${opts.map(([m, l]) =>
|
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>`
|
`<button type="button" class="sys-range-btn ${this.range === m ? 'active' : ''}" data-sys-range="${m}">${l}</button>`
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
chartCard(title, bodyHtml, meta) {
|
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">
|
return `<div class="sys-chart-card">
|
||||||
<div class="sys-chart-head"><span>${title}</span>${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}</div>
|
<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 class="sys-chart-body">${bodyHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared by full render() and the 1 Hz live path so both produce identical
|
// Shared by full render() and the 1 Hz live path so both produce identical
|
||||||
// gauge markup; only `this.d.metrics` differs in source.
|
// 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() {
|
_gaugesHtml() {
|
||||||
const C = window.LPCharts;
|
const C = window.LPCharts;
|
||||||
const m = this.d.metrics || {};
|
const m = this.d.metrics || {};
|
||||||
const cpu = m.cpu || {}, mem = m.memory || {};
|
const cpu = m.cpu || {}, mem = m.memory || {};
|
||||||
const disks = Array.isArray(m.disks) ? m.disks : [];
|
const disks = Array.isArray(m.disks) ? m.disks : [];
|
||||||
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
|
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 `
|
return `
|
||||||
${C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` })}
|
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
|
||||||
${C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` })}
|
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
|
||||||
${C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' })}
|
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
|
||||||
${C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` })}`;
|
${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
|
// Fold a live SSE sample into this.d.metrics and refresh the in-page
|
||||||
@ -159,19 +211,22 @@ class AdminSystem {
|
|||||||
// Trend charts
|
// Trend charts
|
||||||
const rx = this.series('net_rx'), tx = this.series('net_tx');
|
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 lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
|
||||||
|
const hasSwap = (mem.swap_total || 0) > 0;
|
||||||
const charts = `
|
const charts = `
|
||||||
<div class="sys-section-head">
|
<div class="sys-section-head">
|
||||||
<h2>Trends</h2>
|
<h2>Trends</h2>
|
||||||
${this.rangeBtns()}
|
${this.rangeBtns()}
|
||||||
</div>
|
</div>
|
||||||
<div class="sys-charts">
|
<div class="sys-charts">
|
||||||
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %')}
|
${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 %')}
|
${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 || '/')}
|
${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',
|
${this.chartCard('Network',
|
||||||
C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) +
|
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>`,
|
`<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')}
|
'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>`;
|
</div>`;
|
||||||
|
|
||||||
// Host info + swap + docker summary
|
// Host info + swap + docker summary
|
||||||
|
|||||||
@ -0,0 +1,435 @@
|
|||||||
|
// Admin → System — fullscreen single-metric deep-dive overlay.
|
||||||
|
//
|
||||||
|
// Opens when the user hits "Expand" on a gauge, chart card, or any metric
|
||||||
|
// surface on the admin System page. Renders a large interactive chart with
|
||||||
|
// axes, gridlines, hover crosshair + tooltip, and a stat strip (now / peak /
|
||||||
|
// min / avg / Δ) — all driven from /api/system/history + the live SSE feed.
|
||||||
|
// Range picker (1h / 6h / 24h / 7d) re-fetches and animates between datasets.
|
||||||
|
//
|
||||||
|
// Zero dependencies — everything is hand-rolled SVG and pointer events. The
|
||||||
|
// overlay is a singleton instance attached to <body>; opening with a fresh
|
||||||
|
// metric reuses the same DOM.
|
||||||
|
|
||||||
|
class SystemDetail {
|
||||||
|
constructor() {
|
||||||
|
this.el = null;
|
||||||
|
this.metric = null;
|
||||||
|
this.rangeMin = 360; // default to 6 h
|
||||||
|
this.points = [];
|
||||||
|
this.unsubLive = null;
|
||||||
|
this.hoverIdx = -1;
|
||||||
|
this._rafHover = null;
|
||||||
|
this._onKey = this._onKey.bind(this);
|
||||||
|
this._onResize = this._onResize.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: open the overlay for a given metric definition.
|
||||||
|
// m = { key, label, unit, color, max, fmt, sublabel?, accentRgb? }
|
||||||
|
open(m) {
|
||||||
|
this.metric = m;
|
||||||
|
if (!this.el) this._mount();
|
||||||
|
this.el.classList.add('open');
|
||||||
|
this.el.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('sys-detail-active');
|
||||||
|
document.addEventListener('keydown', this._onKey);
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
|
this._renderShell();
|
||||||
|
this._loadRange();
|
||||||
|
this._attachLive();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.el) return;
|
||||||
|
this.el.classList.remove('open');
|
||||||
|
this.el.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('sys-detail-active');
|
||||||
|
document.removeEventListener('keydown', this._onKey);
|
||||||
|
window.removeEventListener('resize', this._onResize);
|
||||||
|
this._detachLive();
|
||||||
|
this.points = [];
|
||||||
|
this.metric = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKey(e) { if (e.key === 'Escape') this.close(); }
|
||||||
|
_onResize() { this._renderChart(); }
|
||||||
|
|
||||||
|
_mount() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'sys-detail';
|
||||||
|
el.setAttribute('role', 'dialog');
|
||||||
|
el.setAttribute('aria-modal', 'true');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="sys-detail-backdrop" data-sys-detail-close></div>
|
||||||
|
<div class="sys-detail-panel" role="document">
|
||||||
|
<header class="sys-detail-head">
|
||||||
|
<div class="sys-detail-title">
|
||||||
|
<div class="sys-detail-eyebrow">Admin · System · Live</div>
|
||||||
|
<h2 class="sys-detail-name"></h2>
|
||||||
|
<p class="sys-detail-sub"></p>
|
||||||
|
</div>
|
||||||
|
<div class="sys-detail-actions">
|
||||||
|
<div class="sys-detail-range" role="tablist" aria-label="Time range">
|
||||||
|
${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']]
|
||||||
|
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn" data-rng="${m}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="sys-detail-close" data-sys-detail-close aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="sys-detail-stats">
|
||||||
|
${['now','peak','avg','min'].map(k =>
|
||||||
|
`<div class="sys-detail-stat" data-stat="${k}">
|
||||||
|
<span class="sys-detail-stat-k">${k}</span>
|
||||||
|
<strong class="sys-detail-stat-v">—</strong>
|
||||||
|
<span class="sys-detail-stat-t"></span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</section>
|
||||||
|
<section class="sys-detail-canvas">
|
||||||
|
<div class="sys-detail-empty" hidden>No samples in this range yet — check back in a minute.</div>
|
||||||
|
<div class="sys-detail-loading">Loading history…</div>
|
||||||
|
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||||
|
<div class="sys-detail-tooltip" hidden></div>
|
||||||
|
</section>
|
||||||
|
<footer class="sys-detail-foot">
|
||||||
|
<span class="sys-detail-foot-meta"></span>
|
||||||
|
<span class="sys-detail-foot-hint">Esc to close · hover the chart to scrub · range tier auto-selects</span>
|
||||||
|
</footer>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
this.el = el;
|
||||||
|
// Wire events once.
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-sys-detail-close]')) { this.close(); return; }
|
||||||
|
const rb = e.target.closest('[data-rng]');
|
||||||
|
if (rb) {
|
||||||
|
this.rangeMin = parseInt(rb.dataset.rng, 10) || 360;
|
||||||
|
this._loadRange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const canvas = el.querySelector('.sys-detail-canvas');
|
||||||
|
canvas.addEventListener('pointermove', (e) => this._onHover(e));
|
||||||
|
canvas.addEventListener('pointerleave', () => this._setHover(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderShell() {
|
||||||
|
const m = this.metric;
|
||||||
|
this.el.querySelector('.sys-detail-name').textContent = m.label;
|
||||||
|
this.el.querySelector('.sys-detail-sub').textContent = m.sublabel || '';
|
||||||
|
const panel = this.el.querySelector('.sys-detail-panel');
|
||||||
|
// Re-key accent so the panel borders + stat strip pick up the metric color.
|
||||||
|
const rgb = m.accentRgb || 'var(--accent-rgb)';
|
||||||
|
panel.style.setProperty('--metric-rgb', rgb);
|
||||||
|
// Range buttons reflect current selection.
|
||||||
|
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
|
||||||
|
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadRange() {
|
||||||
|
const m = this.metric;
|
||||||
|
if (!m) return;
|
||||||
|
const loadingEl = this.el.querySelector('.sys-detail-loading');
|
||||||
|
const emptyEl = this.el.querySelector('.sys-detail-empty');
|
||||||
|
emptyEl.hidden = true;
|
||||||
|
loadingEl.hidden = false;
|
||||||
|
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
|
||||||
|
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(m.key)}`, { credentials: 'same-origin' });
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
this.points = Array.isArray(j?.points) ? j.points : [];
|
||||||
|
this._tier = j?.tier || '1m';
|
||||||
|
} catch (_) {
|
||||||
|
this.points = [];
|
||||||
|
}
|
||||||
|
loadingEl.hidden = true;
|
||||||
|
if (this.points.length === 0) emptyEl.hidden = false;
|
||||||
|
this._renderChart();
|
||||||
|
this._renderStats();
|
||||||
|
this._renderFootMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachLive() {
|
||||||
|
this._detachLive();
|
||||||
|
if (!window.LiveSystem) return;
|
||||||
|
this.unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
||||||
|
}
|
||||||
|
_detachLive() {
|
||||||
|
if (this.unsubLive) { try { this.unsubLive(); } catch (_) {} this.unsubLive = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splice the live sample's current value into the dataset's tail and
|
||||||
|
// refresh the now-stat + the trailing edge of the chart only (cheaper
|
||||||
|
// than a full redraw at 1 Hz). The new point joins the displayed series
|
||||||
|
// but is not persisted to this.points so the next range load starts
|
||||||
|
// clean.
|
||||||
|
_applyLive(s) {
|
||||||
|
if (!s || !this.metric) return;
|
||||||
|
const v = this._extractLive(s);
|
||||||
|
if (v == null) return;
|
||||||
|
this._liveValue = v;
|
||||||
|
this._liveAt = s.t || Date.now();
|
||||||
|
// Append-or-replace: if the latest point in `this.points` is already
|
||||||
|
// tagged as live, replace; otherwise append. Either way we keep the
|
||||||
|
// series length stable so the geometry doesn't jiggle each second.
|
||||||
|
if (this.points.length && this.points[this.points.length - 1].__live) {
|
||||||
|
this.points[this.points.length - 1] = { t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true };
|
||||||
|
} else {
|
||||||
|
this.points.push({ t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true });
|
||||||
|
}
|
||||||
|
this._renderChart();
|
||||||
|
this._renderStats(/*liveOnly=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pluck the metric's current value out of the SSE payload.
|
||||||
|
_extractLive(s) {
|
||||||
|
const k = this.metric.key;
|
||||||
|
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
|
||||||
|
if (k === 'mem') return Number(s?.memory?.percent) || 0;
|
||||||
|
if (k === 'swap') return Number(s?.memory?.swap_percent) || 0;
|
||||||
|
if (k === 'load1') return Number(s?.cpu?.load1) || 0;
|
||||||
|
if (k === 'disk') {
|
||||||
|
const ds = Array.isArray(s?.disks) ? s.disks : [];
|
||||||
|
const r = ds.find(d => d.mount === '/') || ds[0];
|
||||||
|
return r ? Number(r.percent) || 0 : null;
|
||||||
|
}
|
||||||
|
if (k === 'net_rx') return Number(s?.network?.rx_rate) || 0;
|
||||||
|
if (k === 'net_tx') return Number(s?.network?.tx_rate) || 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStats(liveOnly = false) {
|
||||||
|
const m = this.metric;
|
||||||
|
const key = m.key;
|
||||||
|
const vals = this.points.map(p => Number(p[key]) || 0);
|
||||||
|
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
||||||
|
const stats = this._computeStats(vals);
|
||||||
|
const now = vals[vals.length - 1] ?? 0;
|
||||||
|
const nowAt = this.points.length ? this.points[this.points.length - 1].t : null;
|
||||||
|
const peakAt = stats.peakIdx >= 0 ? this.points[stats.peakIdx]?.t : null;
|
||||||
|
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
|
||||||
|
const set = (k, v, t) => {
|
||||||
|
const card = this.el.querySelector(`[data-stat="${k}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.querySelector('.sys-detail-stat-v').textContent = vals.length ? fmt(v) : '—';
|
||||||
|
card.querySelector('.sys-detail-stat-t').textContent = t ? this._timeAgo(t) : '';
|
||||||
|
};
|
||||||
|
set('now', now, nowAt);
|
||||||
|
if (!liveOnly) {
|
||||||
|
set('peak', stats.peak, peakAt);
|
||||||
|
set('avg', stats.avg, null);
|
||||||
|
set('min', stats.min, minAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderFootMeta() {
|
||||||
|
const meta = this.el.querySelector('.sys-detail-foot-meta');
|
||||||
|
const tier = this._tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
||||||
|
const n = this.points.length;
|
||||||
|
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
|
||||||
|
meta.textContent = n
|
||||||
|
? `${n} samples · tier ${tier} · spans ${this._formatDuration(span)}`
|
||||||
|
: `tier ${tier} · empty range`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeStats(vals) {
|
||||||
|
if (!vals.length) return { peak: 0, min: 0, avg: 0, peakIdx: -1, minIdx: -1 };
|
||||||
|
let peak = -Infinity, min = Infinity, sum = 0, peakIdx = 0, minIdx = 0;
|
||||||
|
for (let i = 0; i < vals.length; i++) {
|
||||||
|
const v = vals[i];
|
||||||
|
sum += v;
|
||||||
|
if (v > peak) { peak = v; peakIdx = i; }
|
||||||
|
if (v < min) { min = v; minIdx = i; }
|
||||||
|
}
|
||||||
|
return { peak, min, avg: sum / vals.length, peakIdx, minIdx };
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderChart() {
|
||||||
|
const m = this.metric;
|
||||||
|
if (!m) return;
|
||||||
|
const svg = this.el.querySelector('.sys-detail-svg');
|
||||||
|
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||||
|
const W = canvas.clientWidth || 1200;
|
||||||
|
const H = canvas.clientHeight || 480;
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||||
|
svg.setAttribute('width', W);
|
||||||
|
svg.setAttribute('height', H);
|
||||||
|
if (!this.points.length) { svg.innerHTML = ''; return; }
|
||||||
|
const padL = 64, padR = 24, padT = 18, padB = 36;
|
||||||
|
const innerW = Math.max(1, W - padL - padR);
|
||||||
|
const innerH = Math.max(1, H - padT - padB);
|
||||||
|
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
||||||
|
const stats = this._computeStats(vals);
|
||||||
|
// Y-range: pin to opts.max when given (e.g. percentages); else 0..peak*1.15.
|
||||||
|
const yMin = (m.min !== undefined) ? m.min : 0;
|
||||||
|
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
|
||||||
|
const span = yMax - yMin || 1;
|
||||||
|
const n = vals.length;
|
||||||
|
const stepX = n > 1 ? (innerW / (n - 1)) : 0;
|
||||||
|
const xAt = (i) => padL + i * stepX;
|
||||||
|
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
|
||||||
|
|
||||||
|
// Line + area path.
|
||||||
|
let line = '';
|
||||||
|
for (let i = 0; i < n; i++) line += `${i ? 'L' : 'M'}${xAt(i).toFixed(1)},${yAt(vals[i]).toFixed(1)} `;
|
||||||
|
const area = `${line} L${xAt(n - 1).toFixed(1)},${padT + innerH} L${padL.toFixed(1)},${padT + innerH} Z`;
|
||||||
|
|
||||||
|
// Gridlines + Y labels (5 horizontal ticks, including top + bottom).
|
||||||
|
const yTicks = 5;
|
||||||
|
const fmtY = m.fmt || ((v) => `${Math.round(v)}${m.unit || ''}`);
|
||||||
|
let grid = '', yLabels = '';
|
||||||
|
for (let i = 0; i <= yTicks; i++) {
|
||||||
|
const v = yMax - (i * span / yTicks);
|
||||||
|
const y = padT + (i * innerH / yTicks);
|
||||||
|
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + innerW}" y2="${y.toFixed(1)}" class="sys-detail-grid"/>`;
|
||||||
|
yLabels += `<text x="${(padL - 10).toFixed(0)}" y="${(y + 4).toFixed(0)}" class="sys-detail-axis sys-detail-axis-y">${fmtY(v)}</text>`;
|
||||||
|
}
|
||||||
|
// X labels (~6 ticks across).
|
||||||
|
const xTicks = Math.min(6, n);
|
||||||
|
let xLabels = '';
|
||||||
|
for (let i = 0; i < xTicks; i++) {
|
||||||
|
const idx = Math.round(i * (n - 1) / (xTicks - 1 || 1));
|
||||||
|
const x = xAt(idx);
|
||||||
|
const t = this.points[idx]?.t;
|
||||||
|
xLabels += `<text x="${x.toFixed(0)}" y="${(padT + innerH + 22).toFixed(0)}" class="sys-detail-axis sys-detail-axis-x">${this._fmtTime(t)}</text>`;
|
||||||
|
}
|
||||||
|
// Peak + min markers.
|
||||||
|
const peakDot = (stats.peakIdx >= 0)
|
||||||
|
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
|
||||||
|
const minDot = (stats.minIdx >= 0)
|
||||||
|
? `<circle cx="${xAt(stats.minIdx).toFixed(1)}" cy="${yAt(stats.min).toFixed(1)}" r="3" class="sys-detail-min"/>` : '';
|
||||||
|
// Now dot — pulses via CSS.
|
||||||
|
const nowIdx = n - 1;
|
||||||
|
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
|
||||||
|
// Hover crosshair (positioned in _setHover).
|
||||||
|
const crosshair = `<line class="sys-detail-cross" x1="0" y1="${padT}" x2="0" y2="${(padT + innerH).toFixed(1)}" visibility="hidden"/>
|
||||||
|
<circle class="sys-detail-cross-dot" cx="0" cy="0" r="5" visibility="hidden"/>`;
|
||||||
|
// Gradient id is stable so the fill keeps animating during live updates.
|
||||||
|
const gradId = 'sys-detail-grad';
|
||||||
|
svg.innerHTML = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="rgba(var(--metric-rgb), 0.45)"/>
|
||||||
|
<stop offset="60%" stop-color="rgba(var(--metric-rgb), 0.12)"/>
|
||||||
|
<stop offset="100%" stop-color="rgba(var(--metric-rgb), 0.00)"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="${gradId}-line" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="rgba(var(--metric-rgb), 0.7)"/>
|
||||||
|
<stop offset="100%" stop-color="rgba(var(--metric-rgb), 1.0)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
${grid}
|
||||||
|
<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${(padT + innerH).toFixed(1)}" class="sys-detail-axis-line"/>
|
||||||
|
<line x1="${padL}" y1="${(padT + innerH).toFixed(1)}" x2="${(padL + innerW).toFixed(1)}" y2="${(padT + innerH).toFixed(1)}" class="sys-detail-axis-line"/>
|
||||||
|
<path d="${area}" fill="url(#${gradId})" stroke="none"/>
|
||||||
|
<path d="${line}" fill="none" stroke="url(#${gradId}-line)" stroke-width="2.25"
|
||||||
|
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
${minDot}
|
||||||
|
${peakDot}
|
||||||
|
${nowDot}
|
||||||
|
${crosshair}
|
||||||
|
${yLabels}
|
||||||
|
${xLabels}
|
||||||
|
`;
|
||||||
|
// Cache geometry for hover.
|
||||||
|
this._geo = { padL, padR, padT, padB, innerW, innerH, n, xAt, yAt, vals };
|
||||||
|
}
|
||||||
|
|
||||||
|
_onHover(e) {
|
||||||
|
if (!this._geo) return;
|
||||||
|
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const { padL, innerW, n } = this._geo;
|
||||||
|
const rel = (x - padL) / innerW;
|
||||||
|
if (rel < -0.02 || rel > 1.02) { this._setHover(-1); return; }
|
||||||
|
const idx = Math.max(0, Math.min(n - 1, Math.round(rel * (n - 1))));
|
||||||
|
if (idx === this.hoverIdx) return;
|
||||||
|
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
||||||
|
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
|
||||||
|
}
|
||||||
|
|
||||||
|
_setHover(idx, yCursor) {
|
||||||
|
this.hoverIdx = idx;
|
||||||
|
const svg = this.el.querySelector('.sys-detail-svg');
|
||||||
|
const tooltip = this.el.querySelector('.sys-detail-tooltip');
|
||||||
|
const cross = svg.querySelector('.sys-detail-cross');
|
||||||
|
const dot = svg.querySelector('.sys-detail-cross-dot');
|
||||||
|
if (!cross || !dot) return;
|
||||||
|
if (idx < 0 || !this._geo) {
|
||||||
|
cross.setAttribute('visibility', 'hidden');
|
||||||
|
dot.setAttribute('visibility', 'hidden');
|
||||||
|
tooltip.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { xAt, yAt, vals } = this._geo;
|
||||||
|
const x = xAt(idx);
|
||||||
|
const v = vals[idx];
|
||||||
|
const y = yAt(v);
|
||||||
|
cross.setAttribute('x1', x); cross.setAttribute('x2', x);
|
||||||
|
cross.setAttribute('visibility', 'visible');
|
||||||
|
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
||||||
|
dot.setAttribute('visibility', 'visible');
|
||||||
|
// Tooltip in canvas-pixel space (SVG fills the canvas at native size).
|
||||||
|
const m = this.metric;
|
||||||
|
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
||||||
|
const t = this.points[idx]?.t;
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
||||||
|
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
||||||
|
`;
|
||||||
|
tooltip.hidden = false;
|
||||||
|
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40;
|
||||||
|
// Place to the right of the cursor unless near the right edge.
|
||||||
|
let tx = x + 14, ty = (yCursor !== undefined ? yCursor : y) - th - 8;
|
||||||
|
if (tx + tw > cw - 8) tx = x - tw - 14;
|
||||||
|
if (ty < 8) ty = 8;
|
||||||
|
if (ty + th > ch - 8) ty = ch - th - 8;
|
||||||
|
tooltip.style.transform = `translate(${tx.toFixed(0)}px, ${ty.toFixed(0)}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fmtTime(unixSec) {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const sameDay = (new Date()).toDateString() === d.toDateString();
|
||||||
|
if (this.rangeMin > 1440) {
|
||||||
|
// 7-day view: dd/MM HH:mm
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${dd}/${mm}`;
|
||||||
|
}
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return sameDay ? `${hh}:${min}` : `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${hh}:${min}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fmtTimeFull(unixSec) {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeAgo(unixSec) {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatDuration(sec) {
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)} min`;
|
||||||
|
if (sec < 86400) return `${(sec / 3600).toFixed(1)} h`;
|
||||||
|
return `${(sec / 86400).toFixed(1)} d`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SystemDetail = SystemDetail;
|
||||||
|
window.systemDetail = window.systemDetail || new SystemDetail();
|
||||||
@ -97,7 +97,14 @@ if (typeof window.ConfigManager === 'undefined') {
|
|||||||
// own controller, like SSH Access above.
|
// own controller, like SSH Access above.
|
||||||
if (category === 'system') {
|
if (category === 'system') {
|
||||||
try { this.sidebar.populateSidebar(); } catch (e) {}
|
try { this.sidebar.populateSidebar(); } catch (e) {}
|
||||||
await lazyLoad('/js/components/admin/admin-system.js');
|
// Detail overlay (fullscreen single-metric deep-dive) is its own
|
||||||
|
// file; load it alongside the page controller so "Expand" works
|
||||||
|
// from the first paint.
|
||||||
|
await Promise.all([
|
||||||
|
lazyLoad('/js/components/admin/charts.js'),
|
||||||
|
lazyLoad('/js/components/admin/admin-system.js'),
|
||||||
|
lazyLoad('/js/components/admin/system-detail.js')
|
||||||
|
]);
|
||||||
if (typeof AdminSystem !== 'undefined') {
|
if (typeof AdminSystem !== 'undefined') {
|
||||||
window.adminSystem = new AdminSystem('config-section');
|
window.adminSystem = new AdminSystem('config-section');
|
||||||
await window.adminSystem.init();
|
await window.adminSystem.init();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user