diff --git a/public/admin/assets/js/ops/core.js b/public/admin/assets/js/ops/core.js index 3f4611a..4f876bb 100644 --- a/public/admin/assets/js/ops/core.js +++ b/public/admin/assets/js/ops/core.js @@ -46,40 +46,65 @@ export async function api(path, options = {}) { return parsed; } -// Poll without the spinner flash: keep showing the previous payload while the next -// one is in flight, and never let a slow response overwrite a newer one. -export function usePoll(path, intervalMs = 5000) { - const [state, setState] = useState({ data: null, error: null, loading: true, at: null }); - const seq = useRef(0); - const alive = useRef(true); +// One store per url, shared by every component asking for it. The sidebar and the +// overview both want /ops/overview, and without this they each opened their own +// request on their own timer -- the same payload fetched twice, forever. +const stores = new Map(); - const refresh = useCallback(async () => { - const mine = ++seq.current; - try { - const data = await api(path); - if (!alive.current || mine !== seq.current) return; - setState({ data, error: null, loading: false, at: Date.now() }); - } catch (error) { - if (!alive.current || mine !== seq.current) return; - console.error('[ops] poll failed for', path, error.message); - setState((prev) => ({ ...prev, error: error.message, loading: false })); - } - }, [path]); +function storeFor(path) { + if (stores.has(path)) return stores.get(path); + const store = { + state: { data: null, error: null, loading: true, at: null }, + listeners: new Set(), + inflight: null, + timer: null, + emit() { for (const fn of this.listeners) fn(this.state); }, + async load() { + // a request already on the wire is shared rather than duplicated + if (this.inflight) return this.inflight; + this.inflight = api(path) + .then((data) => { this.state = { data, error: null, loading: false, at: Date.now() }; }) + .catch((error) => { + console.error('[ops] poll failed for', path, error.message); + this.state = { ...this.state, error: error.message, loading: false }; + }) + .finally(() => { this.inflight = null; this.emit(); }); + return this.inflight; + }, + }; + stores.set(path, store); + return store; +} + +export function usePoll(path, intervalMs = 5000) { + const store = storeFor(path); + const [state, setState] = useState(store.state); useEffect(() => { - alive.current = true; - refresh(); - const timer = setInterval(refresh, intervalMs); - const onVisible = () => { if (!document.hidden) refresh(); }; - document.addEventListener('visibilitychange', onVisible); - return () => { - alive.current = false; - clearInterval(timer); - document.removeEventListener('visibilitychange', onVisible); - }; - }, [refresh, intervalMs]); + const listener = (next) => setState(next); + store.listeners.add(listener); + setState(store.state); + store.load(); - return { ...state, refresh }; + // the interval belongs to the store, not the component, so N subscribers still + // produce exactly one request per tick + if (!store.timer) { + store.timer = setInterval(() => { if (!document.hidden) store.load(); }, intervalMs); + } + const onVisible = () => { if (!document.hidden) store.load(); }; + document.addEventListener('visibilitychange', onVisible); + + return () => { + store.listeners.delete(listener); + document.removeEventListener('visibilitychange', onVisible); + if (!store.listeners.size && store.timer) { + clearInterval(store.timer); + store.timer = null; + } + }; + }, [store, intervalMs]); + + return { ...state, refresh: () => store.load() }; } export function useHashRoute(fallback) { diff --git a/src/autonomy/schema.js b/src/autonomy/schema.js index cf912c5..563290e 100644 --- a/src/autonomy/schema.js +++ b/src/autonomy/schema.js @@ -244,6 +244,12 @@ function initAutonomySchema(db) { 'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN replay_run_id INTEGER', 'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN distinct_instruments INTEGER', 'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN top_instrument_share REAL', + // The dashboard groups jobs by type/lane/status on every poll. autonomy_jobs is + // half a million rows and grows by roughly nine thousand a day from the archive + // reconcile loop, so without this that one query was 548ms and rising. + // created_at is included to keep the MAX() index-only. + 'CREATE INDEX IF NOT EXISTS idx_autonomy_jobs_group ON autonomy_jobs(job_type, lane, status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_calibration_snapshot_latest ON autonomy_calibration_snapshots(cohort_key, source, id)', ]) { try { db.exec(statement);