librelad 9f7ad8f177 feat(system): live 1 Hz SSE stream behind admin gauges + dashboard tile
Adds /api/system/stream — a Server-Sent Events feed driven by a single
per-process ticker that reads /proc directly and splices in the latest
host-side metrics.json each second. Subscribers share the connection so
N open tabs cost one ticker, and the ticker pauses entirely when nobody
is listening.

Frontend gets a singleton LiveSystem EventSource manager with auto-
reconnect, Page-Visibility integration (closes on tab hide), and last-
sample replay for late subscribers. Admin -> System gauges and the
dashboard memory + disk tile now tick at 1 Hz; trend charts and the
per-app table keep their 30 s poll because the underlying files only
regenerate once a minute.

Also adds /api/system/history as a thin range-query wrapper over the
existing 24 h JSON ring buffer — the binary ring backend will slot in
behind it in the next phase without changing the response shape.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 20:17:58 +01:00

112 lines
4.1 KiB
JavaScript

// LibrePortal — live system telemetry client.
//
// Singleton EventSource manager. Subscribers register a callback that receives
// each fused live sample pushed by the backend /api/system/stream endpoint.
//
// Design intent:
// - One EventSource per tab no matter how many widgets subscribe — joining a
// stream is cheap, but on a busy page (dashboard + admin → system both
// mounted) several connections would each pin their own /proc reader on
// the backend. One shared connection means one backend ticker, full stop.
// - The connection only opens while at least one subscriber wants it, and
// closes the moment the last unsubscribes — an idle WebUI tab uses zero
// bandwidth and no server resources.
// - Page Visibility integration: a backgrounded tab closes the stream and
// reopens it on return, so a phone left in the pocket doesn't keep a
// connection warm.
// - Auto-reconnect with capped exponential backoff (1 s → 30 s).
// - Late subscribers get the last-known sample synchronously so the UI can
// draw a frame before the next push lands.
window.LiveSystem = (() => {
const ENDPOINT = '/api/system/stream';
const MAX_BACKOFF_MS = 30000;
let es = null;
let last = null;
let backoff = 0;
let reopenTimer = null;
const subs = new Set();
function emit(payload) {
for (const fn of subs) {
try { fn(payload); } catch (_) { /* a misbehaving sub mustn't poison others */ }
}
}
function open() {
if (es || subs.size === 0 || document.hidden) return;
try {
es = new EventSource(ENDPOINT);
} catch (_) {
scheduleReopen();
return;
}
es.onopen = () => { backoff = 0; };
es.onmessage = (ev) => {
// Ignore the keepalive heartbeat (sent as a comment, doesn't fire
// onmessage, but a stray empty data: line might).
if (!ev.data) return;
let payload;
try { payload = JSON.parse(ev.data); } catch (_) { return; }
last = payload;
emit(payload);
};
es.onerror = () => {
// EventSource auto-reconnects on its own, but only while the connection
// hasn't been closed. If the server returned 401/5xx the browser may
// close it; force a manual cycle with backoff so we don't hammer.
close();
scheduleReopen();
};
}
function close() {
if (es) {
try { es.close(); } catch (_) {}
es = null;
}
}
function scheduleReopen() {
if (reopenTimer || subs.size === 0) return;
backoff = backoff ? Math.min(MAX_BACKOFF_MS, backoff * 2) : 1000;
reopenTimer = setTimeout(() => { reopenTimer = null; open(); }, backoff);
}
// Visibility: hide → close, show → reopen if anyone still cares.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
close();
if (reopenTimer) { clearTimeout(reopenTimer); reopenTimer = null; }
} else {
open();
}
});
return {
// Register a callback to receive each live sample. Returns an
// unsubscribe function. If a sample is already in hand it fires
// synchronously so callers can render immediately.
subscribe(fn) {
if (typeof fn !== 'function') return () => {};
subs.add(fn);
if (last) {
try { fn(last); } catch (_) {}
}
open();
return () => {
subs.delete(fn);
if (subs.size === 0) {
close();
if (reopenTimer) { clearTimeout(reopenTimer); reopenTimer = null; }
}
};
},
// Fetch the last sample synchronously (or null if the stream hasn't
// produced one yet). Useful for snapshot-style reads without a sub.
get last() { return last; },
// Test hook: count of active subscribers.
get subCount() { return subs.size; }
};
})();