// Live system metrics — the fast path behind the Admin → System gauges and the // dashboard "pulse" tiles. // // Periodic host-side data (disks, network, docker, per-app, 24 h history) is // produced by the webui_system_metrics generator into frontend/data/system/. // This file serves the *live* path: CPU / memory / load read straight from // /proc, optionally fused with the latest host JSON snapshot so a single SSE // message carries everything a client needs to draw a frame. // // Endpoints: // GET /live — single-shot JSON snapshot (kept for callers that still poll) // GET /stream — Server-Sent Events; pushes a fused sample once per second. // One /proc read per second across all subscribers (shared // ticker), so 100 open tabs still cost one read/sec. // // Namespace note: this runs *inside* the libreportal container. /proc/stat, // /proc/meminfo and /proc/loadavg are not namespaced, so they report host-wide // values that match the generator's numbers. /proc/net/dev IS per-netns (it // would show only this container's traffic), so the host generator owns // network/disk and we splice its latest snapshot into each SSE message. 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(); const CORES = os.cpus().length || 1; const MIN_INTERVAL_MS = 750; // serve cache to anything faster than this const STREAM_TICK_MS = 1000; // SSE push cadence — 1 Hz live feel const HEARTBEAT_MS = 25000; // SSE comment frame to keep proxies from idling out const HOST_JSON_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'system'); let prevCpu = null; // { total, idle } from the last read let cache = null; // { sample, at } let inflight = null; // dedupe concurrent cache-miss reads async function readCpu() { const data = await fs.readFile('/proc/stat', 'utf8'); const first = data.split('\n', 1)[0]; // "cpu u n s i io irq sirq steal ..." const n = first.trim().split(/\s+/).slice(1).map(Number); const idle = (n[3] || 0) + (n[4] || 0); // idle + iowait const total = n.reduce((a, b) => a + (b || 0), 0); return { total, idle }; } async function readMem() { const data = await fs.readFile('/proc/meminfo', 'utf8'); const m = {}; for (const line of data.split('\n')) { const mm = line.match(/^(\w+):\s+(\d+)/); if (mm) m[mm[1]] = parseInt(mm[2], 10) * 1024; // kB -> bytes } const total = m.MemTotal || 0; const available = m.MemAvailable || 0; const used = Math.max(0, total - available); const swapTotal = m.SwapTotal || 0; const swapUsed = Math.max(0, swapTotal - (m.SwapFree || 0)); return { total, used, available, percent: total ? +(used / total * 100).toFixed(1) : 0, swap_total: swapTotal, swap_used: swapUsed, swap_percent: swapTotal ? +(swapUsed / swapTotal * 100).toFixed(1) : 0 }; } async function readLoad() { const data = await fs.readFile('/proc/loadavg', 'utf8'); const [l1, l5, l15] = data.trim().split(/\s+/).map(Number); return { load1: l1 || 0, load5: l5 || 0, load15: l15 || 0 }; } async function sample() { const [cpuNow, memory, load] = await Promise.all([readCpu(), readMem(), readLoad()]); let percent = 0; if (prevCpu) { const dt = cpuNow.total - prevCpu.total; const di = cpuNow.idle - prevCpu.idle; if (dt > 0) percent = +Math.max(0, Math.min(100, (1 - di / dt) * 100)).toFixed(1); } prevCpu = cpuNow; return { cpu: { percent, cores: CORES, load1: load.load1, load5: load.load5, load15: load.load15, load1_percent: +Math.min(100, load.load1 / CORES * 100).toFixed(1) }, memory, t: Date.now() }; } router.get('/live', async (req, res) => { const now = Date.now(); if (cache && (now - cache.at) < MIN_INTERVAL_MS) { res.set('Cache-Control', 'no-store'); return res.json(cache.sample); } try { if (!inflight) { inflight = sample() .then((s) => { cache = { sample: s, at: Date.now() }; return s; }) .finally(() => { inflight = null; }); } const s = await inflight; res.set('Cache-Control', 'no-store'); res.json(s); } catch (err) { res.status(500).json({ error: 'metrics_unavailable' }); } }); // --------------------------------------------------------------------------- // SSE live stream // --------------------------------------------------------------------------- // One ticker for the whole process. Subscribers join/leave; the ticker only // runs while at least one is connected, so an idle WebUI costs nothing. const subscribers = new Set(); let tickHandle = null; let heartbeatHandle = null; let lastSample = null; let hostJson = { metrics: null, disk: null, memory: null, apps: null }; let hostJsonLoadedAt = 0; const HOST_JSON_REFRESH_MS = 5000; // re-read host snapshots every 5 s (they regen at most 1×/min) // Read a JSON file but never throw — missing/invalid → previous value. async function readJsonSafe(file, fallback = null) { try { const txt = await fs.readFile(file, 'utf8'); return JSON.parse(txt); } catch (_) { return fallback; } } // Refresh the cached host-side JSON if it's been at least HOST_JSON_REFRESH_MS // since the last read. Cheap when the files haven't changed because the OS // page cache makes the read essentially free. async function refreshHostJson(now) { if (now - hostJsonLoadedAt < HOST_JSON_REFRESH_MS) return; hostJsonLoadedAt = now; const [metrics, disk, memory, apps] = await Promise.all([ readJsonSafe(path.join(HOST_JSON_DIR, 'metrics.json'), hostJson.metrics), readJsonSafe(path.join(HOST_JSON_DIR, 'disk_usage.json'), hostJson.disk), readJsonSafe(path.join(HOST_JSON_DIR, 'memory_usage.json'), hostJson.memory), readJsonSafe(path.join(HOST_JSON_DIR, 'metrics_apps.json'), hostJson.apps) ]); hostJson = { metrics, disk, memory, apps }; } function ssePayload(s) { // Fuse the live in-container sample with the latest host-side snapshot so // a client gets everything it needs from one stream. The host fields tick // slowly (≤ 1/min) but live alongside the 1 Hz CPU/mem feed. const m = hostJson.metrics || {}; return { t: s.t, cpu: s.cpu, memory: s.memory, disks: Array.isArray(m.disks) ? m.disks : [], network: m.network || { rx_rate: 0, tx_rate: 0 }, docker: m.docker || null, apps: (hostJson.apps && Array.isArray(hostJson.apps.apps)) ? hostJson.apps.apps : [] }; } async function tick() { if (subscribers.size === 0) { // nothing to do — defensive stopTicker(); return; } try { const s = await sample(); const now = Date.now(); cache = { sample: s, at: now }; await refreshHostJson(now); const payload = ssePayload(s); lastSample = payload; const frame = `data: ${JSON.stringify(payload)}\n\n`; for (const res of subscribers) { try { res.write(frame); } catch (_) { /* will be reaped on close */ } } } catch (_) { /* swallow — try again next tick */ } } function startTicker() { if (tickHandle) return; tick(); // fire immediately so the first frame is fresh tickHandle = setInterval(tick, STREAM_TICK_MS); // Heartbeat keeps proxies (Traefik/nginx) from idling the connection out; // SSE comments start with ":" and are ignored by EventSource. heartbeatHandle = setInterval(() => { for (const res of subscribers) { try { res.write(': hb\n\n'); } catch (_) {} } }, HEARTBEAT_MS); } function stopTicker() { if (tickHandle) { clearInterval(tickHandle); tickHandle = null; } if (heartbeatHandle) { clearInterval(heartbeatHandle); heartbeatHandle = null; } } router.get('/stream', async (req, res) => { // SSE handshake. `no-transform` tells the compression middleware not to // gzip this response (gzip buffers and would break streaming). `X-Accel- // Buffering: no` tells nginx/Traefik to flush each event immediately. res.set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' }); res.flushHeaders?.(); // Initial "retry" hint — if the connection dies the browser will reopen // after this many ms (default 3000 is fine but explicit is clearer). res.write('retry: 3000\n\n'); subscribers.add(res); startTicker(); // If we already have a fresh sample, ship it right now so the client doesn't // have to wait STREAM_TICK_MS for its first frame. if (lastSample) { try { res.write(`data: ${JSON.stringify(lastSample)}\n\n`); } catch (_) {} } const cleanup = () => { subscribers.delete(res); if (subscribers.size === 0) stopTicker(); }; req.on('close', cleanup); req.on('error', cleanup); }); // --------------------------------------------------------------------------- // History range query // --------------------------------------------------------------------------- // `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). // // 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(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 { 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 ? pts.map((p) => { const out = { t: p.t }; for (const k of keys) if (k in p) out[k] = p[k]; return out; }) : pts; res.set('Cache-Control', 'no-store'); res.json({ range, tier, points, updated }); } catch (_) { 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;