SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
9.8 KB · 274 lines tsx
Raw Blame History
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