From b9ae512d31ea1f3cdf72a19895dc493de0d31f9e Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 20:20:21 +0100 Subject: [PATCH] =?UTF-8?q?auto:=20session-start=20commit=20=E2=80=94=202?= =?UTF-8?q?=20file(s)=20at=202026-05-24=2020:20:21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../libreportal/backend/routes/routes.js | 2 + .../backend/routes/system-routes.js | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 containers/libreportal/backend/routes/system-routes.js diff --git a/containers/libreportal/backend/routes/routes.js b/containers/libreportal/backend/routes/routes.js index b0ba12f..fed6605 100755 --- a/containers/libreportal/backend/routes/routes.js +++ b/containers/libreportal/backend/routes/routes.js @@ -15,6 +15,7 @@ const authRoutes = require('./auth-routes.js'); const taskRoutes = require('./task-routes.js'); const serviceRoutes = require('./service-routes.js'); const setupRoutes = require('./setup-routes.js'); +const systemRoutes = require('./system-routes.js'); const { testConnection } = require('../utils/mail.js'); module.exports = { @@ -30,6 +31,7 @@ module.exports = { app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside + app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc) app.post('/api/test-mail-connection', requireAuth, testConnection); app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => { diff --git a/containers/libreportal/backend/routes/system-routes.js b/containers/libreportal/backend/routes/system-routes.js new file mode 100644 index 0000000..bf9dbd2 --- /dev/null +++ b/containers/libreportal/backend/routes/system-routes.js @@ -0,0 +1,105 @@ +// 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;