spb/internetpressure
Public
1'use client';23import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';4import type {5 BgpStatsEvent,6 Front,7 GlobalPressure,8 Incident,9 InternalStatus,10 InternalStatusEvent,11 LevelId,12 ProbeStats,13 Region,14 RegionalUpdate,15 ServiceDegradationEvent,16 Snapshot,17 Ticker,18} from './types';1920/**21 * One EventSource per page. State is kept in a tiny external store split into slices; components subscribe with22 * `useLive(selector)` so an SSE message re-renders only the islands whose slice changed (spec §51).23 * Nothing here ever invents motion: every state change corresponds to a received event.24 */25export interface CountryLite {26 cc: string;27 pressure: number;28 level: LevelId;29 delta_1h: number;30}31export interface LiveState {32 global: GlobalPressure | null;33 ticker: Ticker | null;34 regions: Region[] | null;35 countries: CountryLite[] | null;36 fronts: Front[] | null;37 incidents: Incident[] | null;38 bgp: BgpStatsEvent | null;39 probeStats: ProbeStats | null;40 internalStatus: InternalStatus;41 internalReason: string | null;42 serviceDegradations: ServiceDegradationEvent[];43 /** ms timestamp of the last *data* event received (not pings). */44 lastEventAt: number | null;45 /** ISO ts carried by the last engine update — the "frozen since" reference when degraded. */46 lastEngineTs: string | null;47 connection: 'idle' | 'connecting' | 'open' | 'reconnecting';48 /** How many real updates arrived (used to gate number animations: never animate the SSR value). */49 updates: number;50}5152export interface LiveInitial {53 global?: GlobalPressure | null;54 ticker?: Ticker | null;55 regions?: Region[] | null;56 fronts?: Front[] | null;57 incidents?: Incident[] | null;58 bgp?: BgpStatsEvent | null;59}6061type Listener = () => void;6263class LiveStore {64 state: LiveState;65 private listeners = new Set<Listener>();66 constructor(initial: LiveInitial) {67 const g = initial.global ?? null;68 this.state = {69 global: g,70 ticker: initial.ticker ?? null,71 regions: initial.regions ?? null,72 countries: null,73 fronts: initial.fronts ?? null,74 incidents: initial.incidents ?? null,75 bgp: initial.bgp ?? null,76 probeStats: null,77 internalStatus: g?.internal_status ?? initial.ticker?.internal_status ?? 'ok',78 internalReason: null,79 serviceDegradations: [],80 lastEventAt: null,81 lastEngineTs: g?.ts ?? null,82 connection: 'idle',83 updates: 0,84 };85 }86 subscribe = (l: Listener) => {87 this.listeners.add(l);88 return () => {89 this.listeners.delete(l);90 };91 };92 get = () => this.state;93 set(patch: Partial<LiveState>, isData = true) {94 this.state = { ...this.state, ...patch, ...(isData ? { lastEventAt: Date.now(), updates: this.state.updates + 1 } : {}) };95 for (const l of this.listeners) l();96 }97}9899const Ctx = createContext<LiveStore | null>(null);100101function mergeIncident(list: Incident[] | null, inc: Incident): Incident[] {102 const rest = (list ?? []).filter((i) => i.event_id !== inc.event_id);103 const next = inc.status === 'resolved' ? rest : [inc, ...rest];104 return next.sort((a, b) => b.current_pressure - a.current_pressure);105}106107export function LiveProvider({ initial, children, enabled = true }: { initial: LiveInitial; children: ReactNode; enabled?: boolean }) {108 const storeRef = useRef<LiveStore | null>(null);109 if (!storeRef.current) storeRef.current = new LiveStore(initial);110 const store = storeRef.current;111112 useEffect(() => {113 if (!enabled || typeof window === 'undefined' || typeof EventSource === 'undefined') return;114 let es: EventSource | null = null;115 let attempt = 0;116 let timer: ReturnType<typeof setTimeout> | null = null;117 let closed = false;118119 const parse = <T,>(e: MessageEvent): T | null => {120 try {121 return JSON.parse(e.data) as T;122 } catch {123 return null;124 }125 };126127 const open = () => {128 if (closed) return;129 store.set({ connection: attempt === 0 ? 'connecting' : 'reconnecting' }, false);130 es = new EventSource('/api/v1/live');131 es.onopen = () => {132 attempt = 0;133 store.set({ connection: 'open' }, false);134 };135 es.onerror = () => {136 // EventSource retries on its own while CONNECTING; when the browser gives up (CLOSED) we back off and recreate.137 if (es && es.readyState === EventSource.CLOSED) {138 es.close();139 es = null;140 attempt++;141 const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) + Math.random() * 500;142 store.set({ connection: 'reconnecting' }, false);143 timer = setTimeout(open, delay);144 } else {145 store.set({ connection: 'reconnecting' }, false);146 }147 };148 es.addEventListener('snapshot', (e) => {149 const s = parse<Snapshot>(e as MessageEvent);150 if (!s) return;151 store.set({152 global: s.global,153 ticker: s.ticker,154 regions: s.regions,155 fronts: s.fronts,156 incidents: s.incidents,157 internalStatus: s.global?.internal_status ?? s.ticker?.internal_status ?? store.state.internalStatus,158 lastEngineTs: s.global?.ts ?? store.state.lastEngineTs,159 });160 });161 es.addEventListener('global_pressure_update', (e) => {162 const g = parse<GlobalPressure>(e as MessageEvent);163 if (!g) return;164 store.set({ global: g, internalStatus: g.internal_status, lastEngineTs: g.ts, internalReason: g.internal_status === 'ok' ? null : store.state.internalReason });165 });166 es.addEventListener('regional_pressure_update', (e) => {167 const u = parse<RegionalUpdate>(e as MessageEvent);168 if (!u) return;169 store.set({ regions: u.regions, countries: u.countries });170 });171 es.addEventListener('ticker', (e) => {172 const t = parse<Ticker>(e as MessageEvent);173 if (!t) return;174 store.set({ ticker: t, internalStatus: t.internal_status ?? store.state.internalStatus });175 });176 es.addEventListener('bgp_stats', (e) => {177 const b = parse<BgpStatsEvent>(e as MessageEvent);178 if (b) store.set({ bgp: b });179 });180 es.addEventListener('probe_stats', (e) => {181 const p = parse<ProbeStats>(e as MessageEvent);182 if (p) store.set({ probeStats: p });183 });184 es.addEventListener('front_update', (e) => {185 const f = parse<{ fronts: Front[] }>(e as MessageEvent);186 if (f?.fronts) store.set({ fronts: f.fronts });187 });188 const onIncident = (e: Event) => {189 const inc = parse<Incident>(e as MessageEvent);190 if (inc) store.set({ incidents: mergeIncident(store.state.incidents, inc) });191 };192 es.addEventListener('incident_created', onIncident);193 es.addEventListener('incident_updated', onIncident);194 es.addEventListener('service_degradation', (e) => {195 const d = parse<ServiceDegradationEvent>(e as MessageEvent);196 if (d) store.set({ serviceDegradations: [d, ...store.state.serviceDegradations.filter((x) => x.slug !== d.slug)].slice(0, 12) });197 });198 es.addEventListener('internal_status', (e) => {199 const s = parse<InternalStatusEvent>(e as MessageEvent);200 if (s) store.set({ internalStatus: s.internal_status, internalReason: s.reason ?? null }, false);201 });202 };203 open();204 return () => {205 closed = true;206 if (timer) clearTimeout(timer);207 es?.close();208 };209 }, [store, enabled]);210211 return <Ctx.Provider value={store}>{children}</Ctx.Provider>;212}213214const EMPTY = new LiveStore({});215216function shallowEqual(a: unknown, b: unknown): boolean {217 if (Object.is(a, b)) return true;218 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;219 const ka = Object.keys(a as object);220 const kb = Object.keys(b as object);221 if (ka.length !== kb.length) return false;222 for (const k of ka) if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;223 return true;224}225226/**227 * Subscribe to a slice. Selectors may return fresh objects: the result is cached per store state and228 * shallow-compared so useSyncExternalStore sees a stable snapshot (no re-render unless the slice changed).229 */230export function useLive<T>(selector: (s: LiveState) => T): T {231 const store = useContext(Ctx) ?? EMPTY;232 const cache = useRef<{ state: LiveState; result: T } | null>(null);233 const read = () => {234 const state = store.get();235 const c = cache.current;236 if (c && c.state === state) return c.result;237 const result = selector(state);238 if (c && shallowEqual(c.result, result)) {239 cache.current = { state, result: c.result };240 return c.result;241 }242 cache.current = { state, result };243 return result;244 };245 return useSyncExternalStore(store.subscribe, read, read);246}247248/** True when the instrument itself is unhealthy: the number must be shown dimmed and never read as an Internet event. */249export function useDegraded(): { degraded: boolean; status: InternalStatus; reason: string | null; frozenSince: string | null } {250 return useLive((s) => ({251 degraded: s.internalStatus !== 'ok' || Boolean(s.global?.stale),252 status: s.internalStatus,253 reason: s.internalReason,254 frozenSince: s.lastEngineTs,255 }));256}257258/** A clock that ticks every `ms` for relative labels ("3 s ago"). The text updates, the data does not. */259export function useNow(ms = 1000): number {260 const [now, setNow] = useState(() => Date.now());261 useEffect(() => {262 const id = setInterval(() => setNow(Date.now()), ms);263 return () => clearInterval(id);264 }, [ms]);265 return now;266}267268/** Stable helper for components that want the initial (SSR) value merged with the live slice. */269export function useLiveOr<T>(selector: (s: LiveState) => T | null | undefined, fallback: T): T {270 const v = useLive(selector);271 return useMemo(() => v ?? fallback, [v, fallback]);272}273