// 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; }, // Force-close the stream and cancel any pending reopen. Used by the // page lifecycle (pagehide) so the browser can put the page into // BFCache; the visibilitychange handler would normally do this too, // but firing explicitly leaves nothing to race. pause() { close(); if (reopenTimer) { clearTimeout(reopenTimer); reopenTimer = null; } }, // Reopen if anything is still subscribed and the document is visible. // Called from pageshow(persisted=true) when the page is restored from // BFCache. resume() { open(); }, // Test hook: count of active subscribers. get subCount() { return subs.size; } }; })();