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>
124 lines
4.7 KiB
JavaScript
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; }
|
|
};
|
|
})();
|