// main.js — istrain dashboard. Frame chrome (hit counter, refresh / hard-reset) + mounts live panels. // Panels follow the engine's panels//mount(el) contract (see the radio engine). Static placeholder // panels (dispatch, detect) stay in index.html until built. // // Refresh policy: slow background polls + change-aware renders so the page idles at ~0% CPU (no // compositor stutter). Manual "refresh" re-pulls now; "hard reset" kills captures + restarts all helpers (/api/supervisor/reset). import { mountSupervisor } from '/panels/supervisor/supervisor.js'; import { mountChecklist } from '/panels/checklist/checklist.js'; // captures RESTORED 2026-07-10 — the new antenna arrived + whisper transcripts went live; the public // node gets voiced clips pushed. activity stays SHELVED (voice activity = a correlate-graph signal). import { mountCaptures } from '/panels/captures/captures.js'; import { mountEot } from '/panels/eot/eot.js'; import { mountCorrelate } from '/panels/correlate/correlate.js'; import { mountBot } from '/panels/bot/bot.js'; import { mountMidtrain } from '/panels/midtrain/midtrain.js'; import { mountLive } from '/panels/live/live.js'; import { mountTrains } from '/panels/trains/trains.js'; import { mountBanner } from '/panels/banner/banner.js'; import { mountErrors } from '/panels/errors/errors.js'; // --- client-side error reporter → the Errors card (rate-limited + escaped server/panel side) --- function reportErr(msg, src, line) { try { fetch('/api/errors/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msg: String(msg).slice(0, 500), src: String(src || '').slice(0, 200), line: line || 0 }), }).catch(() => {}); } catch { /* the reporter must never itself throw */ } } window.addEventListener('error', e => reportErr(e.message, e.filename, e.lineno)); window.addEventListener('unhandledrejection', e => reportErr('unhandledrejection: ' + ((e.reason && e.reason.message) || e.reason), location.pathname, 0)); // --- hit counter (change-aware; shows "—" until the server exists) --- let lastHits = null; async function loadHits() { try { const r = await fetch('/api/hits', { cache: 'no-store' }); if (!r.ok) return; const { unique, total } = await r.json(); const key = unique + '/' + total; if (key === lastHits) return; // no change → no DOM write lastHits = key; if (unique != null) document.getElementById('hits-unique').textContent = unique.toLocaleString(); if (total != null) document.getElementById('hits-total').textContent = total.toLocaleString(); } catch { /* no server yet — leave the dashes */ } } // --- build stamp: the commit this dashboard serves (VM: git; NAS: VERSION stamped by fetch-app.sh) --- async function loadVersion() { try { const r = await fetch('/api/version', { cache: 'no-store' }); if (!r.ok) return; const { commit } = await r.json(); if (commit) document.getElementById('ver').textContent = commit; } catch { /* leave the dash */ } } // --- unique visitor: a persistent client token (robust behind the tunnel, where the real IP collapses) --- function recordVisit() { let vid = localStorage.getItem('istrain_vid'); if (!vid) { vid = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(16) + '-' + Math.floor(Math.random() * 0xffffffff).toString(16)); localStorage.setItem('istrain_vid', vid); } fetch('/api/hit?vid=' + encodeURIComponent(vid), { cache: 'no-store' }).catch(() => {}).finally(loadHits); } // --- live panels --- const supervisor = mountSupervisor(document.getElementById('supervisor')); const checklist = mountChecklist(document.getElementById('checklist')); const banner = mountBanner(document.getElementById('istrain-banner')); const trains = mountTrains(document.getElementById('trains')); const eot = mountEot(document.getElementById('eot')); const correlate = mountCorrelate(document.getElementById('correlate')); const bot = mountBot(document.getElementById('bot')); const midtrain = mountMidtrain(document.getElementById('midtrain')); const live = mountLive(document.getElementById('live')); const captures = mountCaptures(document.getElementById('captures')); const errors = mountErrors(document.getElementById('errors')); // --- controls --- document.getElementById('btn-refresh').onclick = () => { loadHits(); banner.refresh(); supervisor.refresh(); checklist.refresh(); trains.refresh(); eot.refresh(); correlate.refresh(); bot.refresh(); midtrain.refresh(); errors.refresh(); captures.refresh(); }; document.getElementById('btn-reset').onclick = async () => { if (!confirm('Hard reset — kill the captures and restart all istrain helpers? Frees the dongles; device-level DVB resets still need cmd-palette.')) return; const b = document.getElementById('btn-reset'), t = b.textContent; b.disabled = true; b.textContent = 'resetting…'; try { await fetch('/api/supervisor/reset', { method: 'POST', headers: { 'X-Ops': '1' } }); } catch {} setTimeout(() => { b.disabled = false; b.textContent = t; supervisor.refresh(); eot.refresh(); correlate.refresh(); }, 3500); }; // initial pull + a slow safety poll (manual refresh drives the rest) recordVisit(); // beacon once per load (mints/sends the visitor token), then shows counts loadVersion(); // header build stamp — once per load setInterval(loadHits, 600000); // 10 min