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>
112 lines
4.1 KiB
JavaScript
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; }
|
|
};
|
|
})();
|