feat(monitor/frontend): earthquake + outage panels (left + right column, USGS / CF Radar)

This commit is contained in:
CarterPerez-dev 2026-05-03 19:25:03 -04:00
parent 887dc217d2
commit 6f062bf49b
12 changed files with 509 additions and 6 deletions

View File

@ -10,7 +10,9 @@ export * from './useBgpHijackData'
export * from './useCoinbasePrices'
export * from './useCveData'
export * from './useDShieldData'
export * from './useEarthquakeData'
export * from './useIssPosition'
export * from './useKevData'
export * from './useOutageData'
export * from './useRansomwareData'
export * from './useSpaceWeather'

View File

@ -0,0 +1,25 @@
// ©AngelaMos | 2026
// useEarthquakeData.ts
import { useEffect } from 'react'
import { isValidEarthquakePayload, type EarthquakePayload } from '@/api/types'
import { useSnapshot } from '@/api/snapshot'
import { useEarthquakeStore } from '@/stores/earthquake'
interface EarthquakeData {
items: EarthquakePayload[]
}
export function useEarthquakeData(): EarthquakeData {
const { data } = useSnapshot()
const items = useEarthquakeStore((s) => s.items)
const push = useEarthquakeStore((s) => s.push)
const raw = data?.earthquake
const seed = isValidEarthquakePayload(raw) ? raw : undefined
useEffect(() => {
if (seed) push(seed)
}, [seed, push])
return { items }
}

View File

@ -0,0 +1,25 @@
// ©AngelaMos | 2026
// useOutageData.ts
import { useEffect } from 'react'
import { isValidInternetOutage, type InternetOutage } from '@/api/types'
import { useSnapshot } from '@/api/snapshot'
import { useOutageStore } from '@/stores/outage'
interface OutageData {
items: InternetOutage[]
}
export function useOutageData(): OutageData {
const { data } = useSnapshot()
const items = useOutageStore((s) => s.items)
const push = useOutageStore((s) => s.push)
const raw = data?.internet_outage
const seed = isValidInternetOutage(raw) ? raw : undefined
useEffect(() => {
if (seed) push(seed)
}, [seed, push])
return { items }
}

View File

@ -14,12 +14,25 @@ export const issPositionSchema = z.object({
export type IssPosition = z.infer<typeof issPositionSchema>
export const earthquakePropertiesSchema = z.object({
mag: z.number().nullable().optional(),
place: z.string().nullable().optional(),
time: z.number().optional(),
updated: z.number().optional(),
alert: z.string().nullable().optional(),
tsunami: z.number().optional(),
url: z.string().optional(),
type: z.string().optional(),
})
export type EarthquakeProperties = z.infer<typeof earthquakePropertiesSchema>
export const earthquakePayloadSchema = z.object({
id: z.string(),
geometry: z
.object({ coordinates: z.array(z.number()).optional() })
.optional(),
properties: z.record(z.string(), z.unknown()).optional(),
properties: earthquakePropertiesSchema.optional(),
})
export type EarthquakePayload = z.infer<typeof earthquakePayloadSchema>

View File

@ -6,9 +6,11 @@ import { BGPHijackPanel } from '@/pages/panels/BGPHijackPanel'
import { BTCPanel } from '@/pages/panels/BTCPanel'
import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel'
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
import { EarthquakePanel } from '@/pages/panels/EarthquakePanel'
import { ETHPanel } from '@/pages/panels/ETHPanel'
import { ISSPanel } from '@/pages/panels/ISSPanel'
import { KEVPanel } from '@/pages/panels/KEVPanel'
import { OutagePanel } from '@/pages/panels/OutagePanel'
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel'
import { useUIStore } from '@/stores/ui'
@ -39,6 +41,7 @@ export function Dashboard(): React.ReactElement {
<ETHPanel />
<SpaceWeatherPanel />
<ISSPanel />
<EarthquakePanel />
<CVEVelocityPanel />
</aside>
<section className={styles.center}>
@ -46,6 +49,7 @@ export function Dashboard(): React.ReactElement {
</section>
<aside className={styles.right}>
<BGPHijackPanel />
<OutagePanel />
<KEVPanel />
<RansomwarePanel />
<DShieldPanel />

View File

@ -8,6 +8,7 @@ import {
isValidBgpHijack,
isValidCoinbaseTick,
isValidCveEvent,
isValidDShieldData,
isValidEarthquakePayload,
isValidGdeltSpike,
isValidInternetOutage,
@ -17,6 +18,7 @@ import {
isValidWikiItn,
type BgpHijack,
type CveEvent,
type DShieldData,
type EarthquakePayload,
type GdeltSpike,
type InternetOutage,
@ -30,8 +32,10 @@ import { unlockOnFirstGesture } from '@/lib/audio'
import { getCentroid } from '@/lib/countryCentroids'
import { useBgpHijackStore } from '@/stores/bgpHijack'
import { useCveStore } from '@/stores/cve'
import { useEarthquakeStore } from '@/stores/earthquake'
import { useGlobeEvents } from '@/stores/globeEvents'
import { useKevStore } from '@/stores/kev'
import { useOutageStore } from '@/stores/outage'
import { usePrices } from '@/stores/prices'
import { useRansomwareStore, victimKey } from '@/stores/ransomware'
import { useTicker } from '@/stores/ticker'
@ -101,10 +105,10 @@ export function useDashboardLifecycle(): void {
}
function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
const data = ev.payload
const data = ev.data
if (data === undefined || data === null) return
switch (ev.topic) {
switch (ev.ch) {
case 'cve_new':
if (isValidCveEvent(data)) handleCve(data)
break
@ -135,9 +139,11 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
case 'bgp_hijack':
if (isValidBgpHijack(data)) handleHijack(data, queryClient)
break
case 'space_weather':
case 'scan_firehose':
mergeIntoSnapshot(queryClient, ev.topic, data)
if (isValidDShieldData(data)) handleScanFirehose(data, queryClient)
break
case 'space_weather':
mergeIntoSnapshot(queryClient, ev.ch, data)
break
default:
break
@ -172,6 +178,7 @@ function pushRansomwarePoint(p: RansomwareVictim): void {
}
function handleOutage(p: InternetOutage, queryClient: QueryClient): void {
useOutageStore.getState().push(p)
pushOutagePoints(p)
mergeIntoSnapshot(queryClient, 'internet_outage', p)
}
@ -218,6 +225,28 @@ function pushHijackPoint(p: BgpHijack): void {
})
}
function handleScanFirehose(p: DShieldData, queryClient: QueryClient): void {
pushScanPoints(p)
mergeIntoSnapshot(queryClient, 'scan_firehose', p)
}
function pushScanPoints(p: DShieldData): void {
const now = Date.now()
for (const src of p.topips ?? []) {
if (!src.country) continue
const c = getCentroid(src.country)
if (!c) continue
useGlobeEvents.getState().pushPoint({
id: `scan-${src.source}`,
type: 'scan',
lat: c.lat,
lng: c.lng,
emittedAt: now,
meta: { source: src.source, reports: src.reports },
})
}
}
function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_24h?: string }): void {
usePrices.getState().pushTick({
symbol: p.symbol,
@ -228,6 +257,7 @@ function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_2
}
function handleEarthquake(p: EarthquakePayload): void {
useEarthquakeStore.getState().push(p)
const coords = p.geometry?.coordinates
if (!Array.isArray(coords)) return
const lng = coords[0]
@ -296,6 +326,7 @@ function mergeIntoSnapshot(
function seedGlobeFromSnapshot(snap: Snapshot): void {
const eq = snap.earthquake
if (isValidEarthquakePayload(eq)) {
useEarthquakeStore.getState().push(eq)
const coords = eq.geometry?.coordinates
if (Array.isArray(coords)) {
const lng = coords[0]
@ -328,11 +359,17 @@ function seedGlobeFromSnapshot(snap: Snapshot): void {
if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw)
const outage = snap.internet_outage
if (isValidInternetOutage(outage)) pushOutagePoints(outage)
if (isValidInternetOutage(outage)) {
useOutageStore.getState().push(outage)
pushOutagePoints(outage)
}
const hijack = snap.bgp_hijack
if (isValidBgpHijack(hijack)) {
useBgpHijackStore.getState().push(hijack)
pushHijackPoint(hijack)
}
const scan = snap.scan_firehose
if (isValidDShieldData(scan)) pushScanPoints(scan)
}

View File

@ -0,0 +1,66 @@
// ©AngelaMos | 2026
// EarthquakePanel.module.scss
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.table thead th {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
text-align: left;
padding: var(--row-py) var(--row-px);
border-bottom: 1px solid var(--fg-4);
font-weight: 400;
}
.table tbody td {
padding: var(--row-py) var(--row-px);
font-size: var(--type-body);
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table tbody tr:hover {
background: var(--bg-row-hover);
}
.mag {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 16%;
text-align: right;
}
.place {
width: 64%;
color: var(--fg-1);
}
.ago {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--fg-3);
width: 20%;
text-align: right;
}
.flash {
animation: row-flash 600ms ease-out;
}
@keyframes row-flash {
0% {
background: var(--fg-4);
}
100% {
background: transparent;
}
}

View File

@ -0,0 +1,98 @@
// ©AngelaMos | 2026
// EarthquakePanel.tsx
import { useEffect, useRef, useState } from 'react'
import { useEarthquakeData } from '@/api/hooks'
import { Panel } from './Panel'
import styles from './EarthquakePanel.module.scss'
const QUAKE_ROW_LIMIT = 8
const FLASH_DURATION_MS = 600
const MS_PER_HOUR = 3_600_000
const MS_PER_MINUTE = 60_000
const HOURS_PER_DAY = 24
export function EarthquakePanel(): React.ReactElement {
const { items } = useEarthquakeData()
const seenIds = useRef<Set<string>>(new Set())
const [flashIds, setFlashIds] = useState<Set<string>>(new Set())
useEffect(() => {
const isFirstSeed = seenIds.current.size === 0
const newIds: string[] = []
for (const q of items) {
if (!seenIds.current.has(q.id)) {
seenIds.current.add(q.id)
if (!isFirstSeed) newIds.push(q.id)
}
}
if (newIds.length > 0) setFlashIds(new Set(newIds))
}, [items])
useEffect(() => {
if (flashIds.size === 0) return
const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS)
return () => clearTimeout(t)
}, [flashIds])
const recent = items.slice(0, QUAKE_ROW_LIMIT)
const now = Date.now()
return (
<Panel
title="USGS"
subtitle="QUAKES"
rawHref="https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson"
rawLabel="USGS feed"
>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.mag}>Mag</th>
<th className={styles.place}>Place</th>
<th className={styles.ago}>Ago</th>
</tr>
</thead>
<tbody>
{recent.map((q) => {
const isFlashing = flashIds.has(q.id)
return (
<tr key={q.id} className={isFlashing ? styles.flash : undefined}>
<td className={styles.mag}>{fmtMag(q.properties?.mag)}</td>
<td
className={styles.place}
title={q.properties?.place ?? ''}
>
{q.properties?.place ?? '—'}
</td>
<td className={styles.ago}>
{fmtAgo(q.properties?.time, now)}
</td>
</tr>
)
})}
</tbody>
</table>
</Panel>
)
}
EarthquakePanel.displayName = 'EarthquakePanel'
function fmtMag(mag: number | null | undefined): string {
if (mag === null || mag === undefined) return '—'
return mag.toFixed(1)
}
function fmtAgo(ts: number | undefined, now: number): string {
if (!ts) return '—'
const diff = now - ts
if (diff < 0) return '—'
if (diff < MS_PER_HOUR) {
return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m`
}
if (diff < HOURS_PER_DAY * MS_PER_HOUR) {
return `${Math.floor(diff / MS_PER_HOUR)}h`
}
return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d`
}

View File

@ -0,0 +1,69 @@
// ©AngelaMos | 2026
// OutagePanel.module.scss
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.table thead th {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
text-align: left;
padding: var(--row-py) var(--row-px);
border-bottom: 1px solid var(--fg-4);
font-weight: 400;
}
.table tbody td {
padding: var(--row-py) var(--row-px);
font-size: var(--type-body);
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table tbody tr:hover {
background: var(--bg-row-hover);
}
.cc {
width: 16%;
color: var(--fg-3);
}
.info {
width: 38%;
color: var(--fg-2);
}
.state {
width: 18%;
color: var(--fg-2);
}
.ago {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--fg-3);
width: 28%;
text-align: right;
}
.flash {
animation: row-flash 600ms ease-out;
}
@keyframes row-flash {
0% {
background: var(--fg-4);
}
100% {
background: transparent;
}
}

View File

@ -0,0 +1,118 @@
// ©AngelaMos | 2026
// OutagePanel.tsx
import { useEffect, useRef, useState } from 'react'
import { useOutageData } from '@/api/hooks'
import { Panel } from './Panel'
import styles from './OutagePanel.module.scss'
const OUTAGE_ROW_LIMIT = 6
const FLASH_DURATION_MS = 600
const MS_PER_HOUR = 3_600_000
const MS_PER_MINUTE = 60_000
const HOURS_PER_DAY = 24
export function OutagePanel(): React.ReactElement {
const { items } = useOutageData()
const seenIds = useRef<Set<string>>(new Set())
const [flashIds, setFlashIds] = useState<Set<string>>(new Set())
useEffect(() => {
const isFirstSeed = seenIds.current.size === 0
const newIds: string[] = []
for (const o of items) {
if (!seenIds.current.has(o.id)) {
seenIds.current.add(o.id)
if (!isFirstSeed) newIds.push(o.id)
}
}
if (newIds.length > 0) setFlashIds(new Set(newIds))
}, [items])
useEffect(() => {
if (flashIds.size === 0) return
const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS)
return () => clearTimeout(t)
}, [flashIds])
const recent = items.slice(0, OUTAGE_ROW_LIMIT)
const now = Date.now()
return (
<Panel
title="OUTAGES"
subtitle="CF RADAR"
rawHref="https://radar.cloudflare.com/outage-center"
rawLabel="Cloudflare Radar Outage Center"
>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.cc}>CC</th>
<th className={styles.info}>Cause</th>
<th className={styles.state}>State</th>
<th className={styles.ago}>Started</th>
</tr>
</thead>
<tbody>
{recent.map((o) => {
const isFlashing = flashIds.has(o.id)
return (
<tr key={o.id} className={isFlashing ? styles.flash : undefined}>
<td className={styles.cc}>{fmtCC(o.locations)}</td>
<td
className={styles.info}
title={o.reason ?? o.outageType ?? ''}
>
{fmtCause(o.reason, o.outageType)}
</td>
<td className={styles.state}>{fmtState(o.endDate)}</td>
<td className={styles.ago}>{fmtAgo(o.startDate, now)}</td>
</tr>
)
})}
</tbody>
</table>
</Panel>
)
}
OutagePanel.displayName = 'OutagePanel'
function fmtCC(locations: string[] | undefined): string {
if (!locations || locations.length === 0) return '—'
const first = locations[0] ?? '—'
if (locations.length === 1) return first
return `${first} +${locations.length - 1}`
}
function fmtCause(
reason: string | undefined,
outageType: string | undefined
): string {
if (reason && reason.trim()) return reason
if (outageType && outageType.trim()) return outageType
return '—'
}
function fmtState(endDate: string | null | undefined): string {
if (endDate === null || endDate === undefined) return 'active'
const t = new Date(endDate).getTime()
if (!Number.isFinite(t)) return 'ended'
return 'ended'
}
function fmtAgo(iso: string | undefined, now: number): string {
if (!iso) return '—'
const t = new Date(iso).getTime()
if (!Number.isFinite(t)) return '—'
const diff = now - t
if (diff < 0) return '—'
if (diff < MS_PER_HOUR) {
return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m`
}
if (diff < HOURS_PER_DAY * MS_PER_HOUR) {
return `${Math.floor(diff / MS_PER_HOUR)}h`
}
return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d`
}

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// earthquake.ts
import { create } from 'zustand'
import { type EarthquakePayload } from '@/api/types'
interface EarthquakeStore {
items: EarthquakePayload[]
push: (item: EarthquakePayload) => void
clear: () => void
}
const EARTHQUAKE_CAP = 100
export const useEarthquakeStore = create<EarthquakeStore>((set) => ({
items: [],
push: (item) =>
set((s) => {
const filtered = s.items.filter((i) => i.id !== item.id)
return { items: [item, ...filtered].slice(0, EARTHQUAKE_CAP) }
}),
clear: () => set({ items: [] }),
}))

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// outage.ts
import { create } from 'zustand'
import { type InternetOutage } from '@/api/types'
interface OutageStore {
items: InternetOutage[]
push: (item: InternetOutage) => void
clear: () => void
}
const OUTAGE_CAP = 50
export const useOutageStore = create<OutageStore>((set) => ({
items: [],
push: (item) =>
set((s) => {
const filtered = s.items.filter((i) => i.id !== item.id)
return { items: [item, ...filtered].slice(0, OUTAGE_CAP) }
}),
clear: () => set({ items: [] }),
}))