From aa9a24bdd0a3b839387aa357719e155bb8b4271b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 09:47:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(monitor/frontend):=20dashboard=20lifecycle?= =?UTF-8?q?=20hook=20(snapshot=20bootstrap=20=E2=86=92=20WS=20=E2=86=92=20?= =?UTF-8?q?typed=20routing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDashboardLifecycle() orchestrates the three pieces that make the dashboard alive: 1. Audio gesture unlock — unlockOnFirstGesture() on mount so playChime() is ready when Phase 6 wires alert triggers. 2. Snapshot-then-WS — useSnapshot()'s isSuccess gates createDashboardWS so we never open the WS before initial state is in TanStack cache (per spec §10.2 race resolution). After connect+setReady the server stops buffering and starts streaming deltas. 3. Globe ring eviction — every 5min calls useGlobeEvents.evict(now) so expired pulse rings drop off. Topic routing (typed switch, no `as any` — every payload narrows to a local interface that matches the verified backend shape): cve_new → useCveStore.push kev_added → useKevStore.push ransomware_victim → useRansomwareStore.push coinbase_price → usePrices.pushTick (ISO ts → number, snake_case volume_24h → camelCase) earthquake → globe point + ring pulse (4s TTL) iss_position → globe point (id='iss-current' so each tick replaces the previous) + setQueryData(iss_position) so the ISS panel sees the latest wiki_itn → ticker push (source 'Wikipedia') gdelt_spike → ticker push (source 'GDELT', headline includes z-score and count) space_weather/internet_outage/bgp_hijack/scan_firehose → setQueryData to merge into snapshot (panels read from there) heartbeat → no-op (connection liveness only) Small bug fix in browserDriver: WebSocket.send() throws when readyState is CONNECTING. createDashboardWS.setReady() can be called before the socket opens (we call it immediately after snapshot resolves), so the inner sock.send is now guarded by a readyState===OPEN check. The onOpen handler in createDashboardWS resends init when the socket actually opens — belt-and-suspenders. This is the load-bearing wiring per the plan. Panels stop rendering single snapshot rows and start growing as WS events arrive. --- .../frontend/src/api/ws.ts | 2 +- .../src/pages/dashboard/Dashboard.tsx | 2 + .../frontend/src/pages/dashboard/lifecycle.ts | 238 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts index 017968cc..e24135c5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts @@ -121,7 +121,7 @@ export function browserDriver(url: string, topics: string[]): WSDriver { onMessage: null, onClose: null, send(s) { - sock.send(s) + if (sock.readyState === WebSocket.OPEN) sock.send(s) }, close() { sock.close() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx index 1074ddd7..e93295f0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx @@ -15,10 +15,12 @@ import { About } from './About' import { AlertBanner } from './AlertBanner' import { BottomTicker } from './BottomTicker' import styles from './Dashboard.module.scss' +import { useDashboardLifecycle } from './lifecycle' import { presentationMode } from './presentationMode' import { TopStrip } from './TopStrip' export function Dashboard(): React.ReactElement { + useDashboardLifecycle() presentationMode.useGlobalShortcut() const isPresentation = useUIStore((s) => s.presentationMode) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts new file mode 100644 index 00000000..df0b215e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts @@ -0,0 +1,238 @@ +// ©AngelaMos | 2026 +// lifecycle.ts + +import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { SNAPSHOT_KEY, useSnapshot } from '@/api/snapshot' +import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' +import { unlockOnFirstGesture } from '@/lib/audio' +import { type CveEvent, useCveStore } from '@/stores/cve' +import { useGlobeEvents } from '@/stores/globeEvents' +import { type KevEntry, useKevStore } from '@/stores/kev' +import { usePrices } from '@/stores/prices' +import { type RansomwareVictim, useRansomwareStore } from '@/stores/ransomware' +import { useTicker } from '@/stores/ticker' + +const ALL_TOPICS: readonly string[] = [ + 'heartbeat', + 'scan_firehose', + 'internet_outage', + 'bgp_hijack', + 'cve_new', + 'kev_added', + 'ransomware_victim', + 'coinbase_price', + 'earthquake', + 'space_weather', + 'wiki_itn', + 'gdelt_spike', + 'iss_position', +] + +const WS_URL = '/api/v1/ws' +const GLOBE_RING_TTL_MS = 4_000 +const GLOBE_EVICT_INTERVAL_MS = 5 * 60_000 + +interface CoinbaseTickPayload { + symbol: string + ts: string + price: string + volume_24h?: string +} + +interface IssPositionPayload { + latitude: number + longitude: number + altitude: number + velocity: number + timestamp: number + fetched_at?: string +} + +interface EarthquakePayload { + id: string + geometry?: { coordinates?: number[] } + properties?: Record +} + +interface WikiItnPayload { + text: string + slug: string +} + +interface GdeltSpikePayload { + theme: string + time: string + count: number + zscore: number +} + +export function useDashboardLifecycle(): void { + const { data: snapshot, isSuccess } = useSnapshot() + const queryClient = useQueryClient() + const wsRef = useRef | null>(null) + + useEffect(() => { + unlockOnFirstGesture() + }, []) + + useEffect(() => { + if (!isSuccess) return + + const ws = createDashboardWS({ + driver: () => browserDriver(WS_URL, [...ALL_TOPICS]), + topics: [...ALL_TOPICS], + onEvent: (ev) => routeEvent(ev, queryClient), + }) + wsRef.current = ws + ws.connect() + ws.setReady() + + return () => { + ws.disconnect() + wsRef.current = null + } + }, [isSuccess, queryClient]) + + useEffect(() => { + const id = setInterval(() => { + useGlobeEvents.getState().evict(Date.now()) + }, GLOBE_EVICT_INTERVAL_MS) + return () => clearInterval(id) + }, []) + + void snapshot +} + +function routeEvent(ev: WSEvent, queryClient: QueryClient): void { + const data = ev.payload + if (data === undefined || data === null) return + + switch (ev.topic) { + case 'cve_new': + handleCve(data as CveEvent) + break + case 'kev_added': + handleKev(data as KevEntry) + break + case 'ransomware_victim': + handleRansomware(data as RansomwareVictim) + break + case 'coinbase_price': + handleCoinbase(data as CoinbaseTickPayload) + break + case 'earthquake': + handleEarthquake(data as EarthquakePayload) + break + case 'iss_position': + handleIss(data as IssPositionPayload, queryClient) + break + case 'wiki_itn': + handleWiki(data as WikiItnPayload) + break + case 'gdelt_spike': + handleGdelt(data as GdeltSpikePayload) + break + case 'space_weather': + case 'internet_outage': + case 'bgp_hijack': + case 'scan_firehose': + mergeIntoSnapshot(queryClient, ev.topic, data) + break + default: + break + } +} + +function handleCve(p: CveEvent): void { + if (!p.CveID) return + useCveStore.getState().push(p) +} + +function handleKev(p: KevEntry): void { + if (!p.cveID) return + useKevStore.getState().push(p) +} + +function handleRansomware(p: RansomwareVictim): void { + if (!p.post_title) return + useRansomwareStore.getState().push(p) +} + +function handleCoinbase(p: CoinbaseTickPayload): void { + if (!p.symbol || !p.ts) return + usePrices.getState().pushTick({ + symbol: p.symbol, + ts: new Date(p.ts).getTime(), + price: p.price, + volume24h: p.volume_24h, + }) +} + +function handleEarthquake(p: EarthquakePayload): void { + const coords = p.geometry?.coordinates + if (!Array.isArray(coords)) return + const lng = coords[0] + const lat = coords[1] + if (typeof lat !== 'number' || typeof lng !== 'number') return + const now = Date.now() + useGlobeEvents.getState().pushPoint({ + id: `eq-${p.id}`, + type: 'earthquake', + lat, + lng, + emittedAt: now, + meta: p.properties, + }) + useGlobeEvents.getState().pushRing({ + id: `eq-ring-${p.id}-${now}`, + lat, + lng, + emittedAt: now, + ttlMs: GLOBE_RING_TTL_MS, + }) +} + +function handleIss(p: IssPositionPayload, queryClient: QueryClient): void { + if (typeof p.latitude !== 'number' || typeof p.longitude !== 'number') return + useGlobeEvents.getState().pushPoint({ + id: 'iss-current', + type: 'iss', + lat: p.latitude, + lng: p.longitude, + emittedAt: Date.now(), + }) + mergeIntoSnapshot(queryClient, 'iss_position', p) +} + +function handleWiki(p: WikiItnPayload): void { + if (!p.text) return + const id = `wiki-${p.slug || p.text}` + useTicker.getState().push({ + id, + source: 'Wikipedia', + headline: p.text, + ts: Date.now(), + }) +} + +function handleGdelt(p: GdeltSpikePayload): void { + if (!p.theme) return + useTicker.getState().push({ + id: `gdelt-${p.theme}-${p.time}`, + source: 'GDELT', + headline: `${p.theme} spike (z=${p.zscore.toFixed(2)}, n=${p.count})`, + ts: Date.now(), + }) +} + +function mergeIntoSnapshot( + queryClient: QueryClient, + topic: string, + data: unknown +): void { + queryClient.setQueryData>(SNAPSHOT_KEY, (prev) => ({ + ...(prev ?? {}), + [topic]: data, + })) +}