Repository Public
1"use client";23import { useEffect, useState, useSyncExternalStore } from "react";4import type { HistoryWindow, LiveHistory, LiveSnapshot, LiveSpark } from "@/lib/live-types";56/**7 * One shared poller per endpoint: every component on a page reads the same snapshot, a single request is in flight,8 * and polling stops when the tab is hidden or when nothing is mounted.9 */10function createPoller<T>(url: string, everyMs: number) {11 let value: T | null = null;12 let failed = false;13 let timer: ReturnType<typeof setInterval> | null = null;14 const listeners = new Set<() => void>();15 let state: { value: T | null; failed: boolean } = { value, failed };16 const emit = () => {17 state = { value, failed };18 listeners.forEach((l) => l());19 };20 const tick = async () => {21 if (typeof document !== "undefined" && document.hidden) return;22 try {23 const res = await fetch(url, { cache: "no-store" });24 if (!res.ok) throw new Error(String(res.status));25 value = (await res.json()) as T;26 failed = false;27 } catch {28 failed = true;29 }30 emit();31 };32 const onVisible = () => {33 if (!document.hidden) void tick();34 };35 return {36 subscribe(l: () => void) {37 listeners.add(l);38 if (listeners.size === 1) {39 void tick();40 timer = setInterval(tick, everyMs);41 document.addEventListener("visibilitychange", onVisible);42 }43 return () => {44 listeners.delete(l);45 if (listeners.size === 0) {46 if (timer) clearInterval(timer);47 timer = null;48 document.removeEventListener("visibilitychange", onVisible);49 }50 };51 },52 get: () => state,53 };54}5556const SERVER = { value: null, failed: false };57const snapshotPoller = createPoller<LiveSnapshot>("/api/live", 10_000);58const sparkPoller = createPoller<LiveSpark>("/api/live/spark", 60_000);5960/** `ready` is false until the first answer; `snap` is null when telemetry is unavailable. */61export function useLive(): { ready: boolean; snap: LiveSnapshot | null } {62 const s = useSyncExternalStore(snapshotPoller.subscribe, snapshotPoller.get, () => SERVER as { value: LiveSnapshot | null; failed: boolean });63 const ok = s.value?.available ? s.value : null;64 return { ready: s.value !== null || s.failed, snap: ok };65}6667export function useSpark(): Record<string, number[]> {68 const s = useSyncExternalStore(sparkPoller.subscribe, sparkPoller.get, () => SERVER as { value: LiveSpark | null; failed: boolean });69 return s.value?.nodes ?? EMPTY_SPARK;70}71const EMPTY_SPARK: Record<string, number[]> = {};7273export function useHistory(target: string, window: HistoryWindow): { loading: boolean; history: LiveHistory | null } {74 const key = `${target}|${window}`;75 const [state, setState] = useState<{ key: string; history: LiveHistory | null } | null>(null);76 useEffect(() => {77 let alive = true;78 const load = () =>79 fetch(`/api/live/history?node=${encodeURIComponent(target)}&window=${window}`, { cache: "no-store" })80 .then((r) => (r.ok ? (r.json() as Promise<LiveHistory>) : null))81 .catch(() => null)82 .then((h) => {83 if (alive) setState({ key, history: h });84 });85 void load();86 const id = setInterval(() => {87 if (!document.hidden) void load();88 }, 60_000);89 return () => {90 alive = false;91 clearInterval(id);92 };93 }, [key, target, window]);94 const current = state && state.key === key ? state : null;95 return { loading: current === null, history: current?.history ?? null };96}9798/** Seconds elapsed since `ts`, re-evaluated every second. */99export function useAge(ts: number | undefined): number | null {100 const [now, setNow] = useState<number | null>(null);101 useEffect(() => {102 const id = setInterval(() => setNow(Date.now()), 1000);103 const first = setTimeout(() => setNow(Date.now()), 0);104 return () => {105 clearInterval(id);106 clearTimeout(first);107 };108 }, []);109 return ts && now ? Math.max(0, Math.round((now - ts) / 1000)) : null;110}111