feat(monitor/frontend): dashboard lifecycle hook (snapshot bootstrap → WS → typed routing)

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.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:47:40 -04:00
parent 71e0529960
commit aa9a24bdd0
3 changed files with 241 additions and 1 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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<string, unknown>
}
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<ReturnType<typeof createDashboardWS> | 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<Record<string, unknown>>(SNAPSHOT_KEY, (prev) => ({
...(prev ?? {}),
[topic]: data,
}))
}