spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
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 [store] = useState(() => new LiveStore(initial));109 useEffect(() => {110 if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipLive?: LiveStore }).__ipLive = store;111 }, [store]);112113 useEffect(() => {114 if (!enabled || typeof window === 'undefined' || typeof EventSource === 'undefined') return;115 let es: EventSource | null = null;116 let attempt = 0;117 let timer: ReturnType<typeof setTimeout> | null = null;118 let closed = false;119120 const parse = <T,>(e: MessageEvent): T | null => {121 try {122 return JSON.parse(e.data) as T;123 } catch {124 return null;125 }126 };127128 const open = () => {129 if (closed) return;130 store.set({ connection: attempt === 0 ? 'connecting' : 'reconnecting' }, false);131 es = new EventSource('/api/v1/live');132 es.onopen = () => {133 attempt = 0;134 store.set({ connection: 'open' }, false);135 };136 es.onerror = () => {137 // EventSource retries on its own while CONNECTING; when the browser gives up (CLOSED) we back off and recreate.138 if (es && es.readyState === EventSource.CLOSED) {139 es.close();140 es = null;141 attempt++;142 const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) + Math.random() * 500;143 store.set({ connection: 'reconnecting' }, false);144 timer = setTimeout(open, delay);145 } else {146 store.set({ connection: 'reconnecting' }, false);147 }148 };149 es.addEventListener('snapshot', (e) => {150 const s = parse<Snapshot>(e as MessageEvent);151 if (!s) return;152 store.set({153 global: s.global,154 ticker: s.ticker,155 regions: s.regions,156 fronts: s.fronts,157 incidents: s.incidents,158 internalStatus: s.global?.internal_status ?? s.ticker?.internal_status ?? store.state.internalStatus,159 lastEngineTs: s.global?.ts ?? store.state.lastEngineTs,160 });161 });162 es.addEventListener('global_pressure_update', (e) => {163 const g = parse<GlobalPressure>(e as MessageEvent);164 if (!g) return;165 store.set({ global: g, internalStatus: g.internal_status, lastEngineTs: g.ts, internalReason: g.internal_status === 'ok' ? null : store.state.internalReason });166 });167 es.addEventListener('regional_pressure_update', (e) => {168 const u = parse<RegionalUpdate>(e as MessageEvent);169 if (!u) return;170 store.set({ regions: u.regions, countries: u.countries });171 });172 es.addEventListener('ticker', (e) => {173 const t = parse<Ticker>(e as MessageEvent);174 if (!t) return;175 store.set({ ticker: t, internalStatus: t.internal_status ?? store.state.internalStatus });176 });177 es.addEventListener('bgp_stats', (e) => {178 const b = parse<BgpStatsEvent>(e as MessageEvent);179 if (b) store.set({ bgp: b });180 });181 es.addEventListener('probe_stats', (e) => {182 const p = parse<ProbeStats>(e as MessageEvent);183 if (p) store.set({ probeStats: p });184 });185 es.addEventListener('front_update', (e) => {186 const f = parse<{ fronts: Front[] }>(e as MessageEvent);187 if (f?.fronts) store.set({ fronts: f.fronts });188 });189 const onIncident = (e: Event) => {190 const inc = parse<Incident>(e as MessageEvent);191 if (inc) store.set({ incidents: mergeIncident(store.state.incidents, inc) });192 };193 es.addEventListener('incident_created', onIncident);194 es.addEventListener('incident_updated', onIncident);195 es.addEventListener('service_degradation', (e) => {196 const d = parse<ServiceDegradationEvent>(e as MessageEvent);197 if (d) store.set({ serviceDegradations: [d, ...store.state.serviceDegradations.filter((x) => x.slug !== d.slug)].slice(0, 12) });198 });199 es.addEventListener('internal_status', (e) => {200 const s = parse<InternalStatusEvent>(e as MessageEvent);201 if (s) store.set({ internalStatus: s.internal_status, internalReason: s.reason ?? null }, false);202 });203 };204 open();205 return () => {206 closed = true;207 if (timer) clearTimeout(timer);208 es?.close();209 };210 }, [store, enabled]);211212 return <Ctx.Provider value={store}>{children}</Ctx.Provider>;213}214215const EMPTY = new LiveStore({});216217function shallowEqual(a: unknown, b: unknown): boolean {218 if (Object.is(a, b)) return true;219 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;220 const ka = Object.keys(a as object);221 const kb = Object.keys(b as object);222 if (ka.length !== kb.length) return false;223 for (const k of ka) if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;224 return true;225}226227/**228 * Subscribe to a slice. Selectors may return fresh objects: the result is cached per store state and229 * shallow-compared so useSyncExternalStore sees a stable snapshot (no re-render unless the slice changed).230 */231export function useLive<T>(selector: (s: LiveState) => T): T {232 const store = useContext(Ctx) ?? EMPTY;233 const cache = useRef<{ state: LiveState; result: T } | null>(null);234 const read = () => {235 const state = store.get();236 const c = cache.current;237 if (c && c.state === state) return c.result;238 const result = selector(state);239 if (c && shallowEqual(c.result, result)) {240 cache.current = { state, result: c.result };241 return c.result;242 }243 cache.current = { state, result };244 return result;245 };246 return useSyncExternalStore(store.subscribe, read, read);247}248249/** True when the instrument itself is unhealthy: the number must be shown dimmed and never read as an Internet event. */250export function useDegraded(): { degraded: boolean; status: InternalStatus; reason: string | null; frozenSince: string | null } {251 return useLive((s) => ({252 degraded: s.internalStatus !== 'ok' || Boolean(s.global?.stale),253 status: s.internalStatus,254 reason: s.internalReason,255 frozenSince: s.lastEngineTs,256 }));257}258259/** A clock that ticks every `ms` for relative labels ("3 s ago"). The text updates, the data does not. */260export function useNow(ms = 1000): number {261 const [now, setNow] = useState(() => Date.now());262 useEffect(() => {263 const id = setInterval(() => setNow(Date.now()), ms);264 return () => clearInterval(id);265 }, [ms]);266 return now;267}268269/** Stable helper for components that want the initial (SSR) value merged with the live slice. */270export function useLiveOr<T>(selector: (s: LiveState) => T | null | undefined, fallback: T): T {271 const v = useLive(selector);272 return useMemo(() => v ?? fallback, [v, fallback]);273}274