feat(portal): live ~1Hz WS stream with auto-close (visibility + idle cap)

The portal location view is now genuinely live, not a 15s poll. Scoped WS endpoint
/portal/api/location/{id}/stream: authenticates via the session cookie, enforces
ownership (resolve_client_location), then bridges the unit's shared SLMM /monitor
fan-out feed to the browser — a viewer is just one more subscriber, no extra
device connection. Frames are scrubbed to the portal whitelist (drops unit_id,
raw_payload, counter, lmin) before reaching the client.

location.html: cache prefill for instant first paint, then upgrades to the live
socket (cards tick ~1Hz, chart scrolls). Auto-close so an abandoned tab can't pin
the device at 1Hz polling (~8x cellular data):
- closes when the tab is hidden, reopens when visible (Page Visibility) — the main
  guard;
- hard 15-min cap -> "Live paused — click to resume" overlay.

Refactor: client_from_cookie() extracted from get_current_client so the WS handler
(no Request-based Depends) can auth the same way.

Verified: scrub drops internal fields / keeps metrics + heartbeat (7/7), auth
refactor (3/3), portal compiles, location.html JS balances + parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 03:16:32 +00:00
parent 3fc20e104a
commit 0103917870
3 changed files with 183 additions and 18 deletions
+78 -4
View File
@@ -39,8 +39,14 @@
</div>
</div>
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
<div class="relative rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
<canvas id="p-chart"></canvas>
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-slate-900/70 rounded-xl">
<button onclick="resumeStream()"
class="px-4 py-2 rounded-lg bg-seismo-orange/20 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/30 text-sm font-medium">
&#9208; Live paused — click to resume
</button>
</div>
</div>
{% endif %}
{% endblock %}
@@ -129,10 +135,78 @@ async function backfill() {
} catch (e) { /* leave chart empty */ }
}
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
let ws = null, hardCap = null, paused = false;
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
// tab doesn't pin the device at 1Hz polling
function pushPoint(d) {
cd.t.push(new Date().toLocaleTimeString());
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
chart.data.labels = cd.t;
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
chart.update('none');
}
function openStream() {
if (paused || ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
ws.onmessage = (e) => {
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
if (d.feed_status === 'no_device') {
setBadge(null, null);
document.getElementById('p-fresh').textContent = 'No device assigned';
return;
}
if (d.heartbeat) return;
if (d.feed_status === 'unreachable') {
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
return;
}
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.timestamp || new Date().toISOString());
pushPoint(d);
};
ws.onclose = () => { ws = null; };
ws.onerror = () => {};
clearTimeout(hardCap);
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
}
function closeStream() {
clearTimeout(hardCap);
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
}
function showPaused(on) {
const el = document.getElementById('p-paused');
if (el) el.classList.toggle('hidden', !on);
}
function resumeStream() {
paused = false; showPaused(false);
prefill(); // refresh cards instantly on resume
openStream();
}
// Stop streaming when the tab is hidden (client switched away / locked phone) and
// resume when it's visible again — the main cost guard, so the device relaxes back
// to its idle poll rate the moment nobody is actually looking.
document.addEventListener('visibilitychange', () => {
if (document.hidden) closeStream();
else if (!paused) openStream();
});
window.addEventListener('beforeunload', closeStream);
initChart();
prefill();
backfill();
setInterval(prefill, 15000); // cache poll — read-only, no device contention
prefill(); // instant first paint from cache
backfill(); // seed the chart trail
openStream(); // then upgrade to the live feed
</script>
{% endif %}
{% endblock %}