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:
parent
71e0529960
commit
aa9a24bdd0
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
Loading…
Reference in New Issue