106 lines
4.0 KiB
JavaScript
106 lines
4.0 KiB
JavaScript
// 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;
|