SPB Git
9.8 KB · 273 lines tsx
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
Files · be6a3c4c9e1838cf98ba8aa0b95cc0ee163b31bc
Menu

Theme