// Live system metrics — the fast path behind the Admin → System gauges. // // The periodic, host-side picture (disk, network, docker, per-app, 24h history) // is produced by the webui_system_metrics generator into frontend/data/system/. // This endpoint exists only to make the headline gauges *tick* every couple of // seconds, so it is deliberately the cheapest, safest thing it can be: // - reads only /proc (no subprocess spawn, no docker, no privileges) // - CPU% from an in-memory delta of the previous /proc/stat read // - a single-flight + short cache so N open tabs cause ~1 /proc 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 network is intentionally left // to the host-side generator and not served here. const express = require('express'); const fs = require('fs').promises; const os = require('os'); const router = express.Router(); const CORES = os.cpus().length || 1; const MIN_INTERVAL_MS = 750; // serve cache to anything faster than this 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' }); } }); module.exports = router;