librelad dd1264e335 ui(spa): stamp initial history entry + close live buses on pagehide so back-button works like a real SPA
Two reasons the back button was unreliable:

1. The very first history entry (the URL the user landed on) had
   state: null because handleInitialRoute() called navigate(path,
   false), and the pushState branch only ran when addToHistory=true.
   When the user later pushState'd forward and then hit back, the
   popstate handler's guard "e.state && e.state.route" was false on
   the initial entry, so it silently did nothing — back appeared
   broken. Now navigate() replaceState's the current entry whenever
   addToHistory=false, so the initial entry (and any back-compat
   URL rewrite) always carries its route. The popstate handler also
   now falls back to window.location when state.route is missing,
   so third-party history manipulation can't break us.

2. Open SSE streams (LiveSystem, taskEventBus, services-manager log
   tails) block the browser's back-forward cache. Without BFCache,
   back has to fully re-mount the page instead of restoring it
   instantly the way Amazon/GitHub feel. Now pagehide closes every
   live bus we own, and pageshow(persisted=true) reopens them when
   the page is restored from BFCache. Log tails aren't auto-resumed
   — Resume overlay handles that if the user comes back to a
   services tab.

Public surface added: LiveSystem.pause()/resume() and
ServicesManager.pauseStreams(). TaskEventBus already had stop()/
start(). The legacy-URL rewrite in handleAppDetail also now
replaceState's with { route: canonical } instead of {} so the
stamp is consistent across all internal history updates.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 23:28:25 +01:00

124 lines
4.7 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; },
// 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; }
};
})();