diff --git a/containers/libreportal/backend/routes/system-routes.js b/containers/libreportal/backend/routes/system-routes.js index 4cc502f..455ce92 100644 --- a/containers/libreportal/backend/routes/system-routes.js +++ b/containers/libreportal/backend/routes/system-routes.js @@ -22,6 +22,7 @@ const express = require('express'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); +const metricsWriter = require('../utils/metrics-writer.js'); const router = express.Router(); @@ -238,36 +239,61 @@ router.get('/stream', async (req, res) => { // --------------------------------------------------------------------------- // History range query // --------------------------------------------------------------------------- -// Reads the 24 h ring buffer the host-side generator writes and returns a -// slice. `range` is minutes back from now (1..1440). `keys` is a comma-list of -// metric names to project (defaults to the whole point). +// `range` is minutes back from now (1..10080 = 7 days). `keys` is an optional +// comma-list of metric names to project (defaults to the whole point). // -// This is a thin wrapper today — the buffer lives on disk as metrics_history. -// json. A binary ring-buffer backend will slot in here in Phase 2 without -// changing the response shape. +// Two on-disk binary rings back this: +// 1m tier — 1440 pts @ 1-min (24 h) +// 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) => { - 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 ? req.query.keys.split(',').map((s) => s.trim()).filter(Boolean) : null; try { - const file = path.join(HOST_JSON_DIR, 'metrics_history.json'); - const raw = await fs.readFile(file, 'utf8'); - const parsed = JSON.parse(raw); - const all = Array.isArray(parsed?.points) ? parsed.points : []; - const sliced = all.slice(-range); + let pts = await metricsWriter.read(wantPoints, tier); + let updated = null; + if (pts.length) { + updated = new Date(pts[pts.length - 1].t * 1000).toISOString(); + } 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 - ? sliced.map((p) => { + ? pts.map((p) => { const out = { t: p.t }; for (const k of keys) if (k in p) out[k] = p[k]; return out; }) - : sliced; + : pts; res.set('Cache-Control', 'no-store'); - res.json({ range, points, updated: parsed?.updated || null }); + res.json({ range, tier, points, updated }); } 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; diff --git a/containers/libreportal/backend/utils/metrics-ring.js b/containers/libreportal/backend/utils/metrics-ring.js new file mode 100644 index 0000000..28195ca --- /dev/null +++ b/containers/libreportal/backend/utils/metrics-ring.js @@ -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 }; diff --git a/containers/libreportal/backend/utils/metrics-writer.js b/containers/libreportal/backend/utils/metrics-writer.js new file mode 100644 index 0000000..b241633 --- /dev/null +++ b/containers/libreportal/backend/utils/metrics-writer.js @@ -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, +}; diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index dc30c3d..2588a19 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -178,7 +178,13 @@ border: 1px solid rgba(var(--text-rgb), 0.10); border-radius: 12px; 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 { display: flex; 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-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; } +} diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js index 9a4695e..cc0a509 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-system.js +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -49,9 +49,12 @@ class AdminSystem { } async refresh() { + // History now comes from /api/system/history (binary ring backed, + // supports up to 7 days). Everything else stays on the static JSONs + // the host generator writes. const [metrics, history, apps, appsHist, info] = await Promise.all([ this.fetchJson('/data/system/metrics.json'), - this.fetchJson('/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_history.json'), this.fetchJson('/data/system/system_info.json') @@ -69,14 +72,49 @@ class AdminSystem { if (this._bound) return; this._bound = true; document.addEventListener('click', (e) => { + if (!document.querySelector('.sys-page')) return; const rb = e.target.closest('[data-sys-range]'); - if (rb && document.querySelector('.sys-page')) { + if (rb) { 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 ---- */ escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } bytes(n) { @@ -94,32 +132,46 @@ class AdminSystem { } rangeBtns() { - const opts = [[60, '1h'], [360, '6h'], [1440, '24h']]; + const opts = [[60, '1h'], [360, '6h'], [1440, '24h'], [10080, '7d']]; return `
${opts.map(([m, l]) => `` ).join('')}
`; } - chartCard(title, bodyHtml, meta) { + chartCard(title, bodyHtml, meta, expandKey) { + const exBtn = expandKey + ? `` : ''; return `
-
${title}${meta ? `${meta}` : ''}
+
${title}${meta ? `${meta}` : ''}${exBtn}
${bodyHtml}
`; } // Shared by full render() and the 1 Hz live path so both produce identical // gauge markup; only `this.d.metrics` differs in source. + // Each gauge is wrapped in a button surface (data-sys-expand=) so a + // click anywhere on the gauge opens the fullscreen detail overlay for + // that metric. _gaugesHtml() { const C = window.LPCharts; const m = this.d.metrics || {}; const cpu = m.cpu || {}, mem = m.memory || {}; const disks = Array.isArray(m.disks) ? m.disks : []; const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {}; + const wrap = (key, inner) => + ``; return ` - ${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)}` })} - ${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('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))} + ${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))} + ${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))} + ${wrap('load1', C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`; } // Fold a live SSE sample into this.d.metrics and refresh the in-page @@ -159,19 +211,22 @@ class AdminSystem { // Trend charts const rx = this.series('net_rx'), tx = this.series('net_tx'); const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0; + const hasSwap = (mem.swap_total || 0) > 0; const charts = `

Trends

${this.rangeBtns()}
- ${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', 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 %')} - ${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/')} + ${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'cpu')} + ${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'mem')} + ${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/', 'disk')} ${this.chartCard('Network', C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) + `
↓ ${this.rate(lastRx)}↑ ${this.rate(lastTx)}
`, - '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') : ''}
`; // Host info + swap + docker summary diff --git a/containers/libreportal/frontend/js/components/admin/system-detail.js b/containers/libreportal/frontend/js/components/admin/system-detail.js new file mode 100644 index 0000000..5e5568c --- /dev/null +++ b/containers/libreportal/frontend/js/components/admin/system-detail.js @@ -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 ; 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 = ` +
+
+
+
+
Admin · System · Live
+

+

+
+
+
+ ${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']] + .map(([m,l]) => ``).join('')} +
+ +
+
+
+ ${['now','peak','avg','min'].map(k => + `
+ ${k} + + +
`).join('')} +
+
+ +
Loading history…
+ + +
+
+ + Esc to close · hover the chart to scrub · range tier auto-selects +
+
`; + 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 += ``; + yLabels += `${fmtY(v)}`; + } + // 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 += `${this._fmtTime(t)}`; + } + // Peak + min markers. + const peakDot = (stats.peakIdx >= 0) + ? `` : ''; + const minDot = (stats.minIdx >= 0) + ? `` : ''; + // Now dot — pulses via CSS. + const nowIdx = n - 1; + const nowDot = ``; + // Hover crosshair (positioned in _setHover). + const crosshair = ` + `; + // Gradient id is stable so the fill keeps animating during live updates. + const gradId = 'sys-detail-grad'; + svg.innerHTML = ` + + + + + + + + + + + + ${grid} + + + + + ${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 = ` +
${fmt(v)}
+
${this._fmtTimeFull(t)}
+ `; + 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(); diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js index f79ed84..ce2c120 100755 --- a/containers/libreportal/frontend/js/components/config/config-manager.js +++ b/containers/libreportal/frontend/js/components/config/config-manager.js @@ -97,7 +97,14 @@ if (typeof window.ConfigManager === 'undefined') { // own controller, like SSH Access above. if (category === 'system') { 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') { window.adminSystem = new AdminSystem('config-section'); await window.adminSystem.init();