From 0f53e6723647342823ae3bb05bb6b1e58315a5ba Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 19:06:11 -0400 Subject: [PATCH] feat(monitor/frontend): bgp hijack panel + globe plot via abuseipdb-enriched country --- .../frontend/src/api/hooks/index.ts | 8 + .../src/api/hooks/useBgpHijackData.ts | 25 +++ .../frontend/src/api/types/geo.types.ts | 82 +++++++++ .../frontend/src/pages/dashboard/lifecycle.ts | 169 +++++++++--------- .../pages/panels/BGPHijackPanel.module.scss | 77 ++++++++ .../src/pages/panels/BGPHijackPanel.tsx | 108 +++++++++++ .../frontend/src/stores/bgpHijack.ts | 23 +++ 7 files changed, 406 insertions(+), 86 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts index 1bc139b2..7dd74f60 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts @@ -6,3 +6,11 @@ export * from './useAdmin' export * from './useAuth' export * from './useUsers' +export * from './useBgpHijackData' +export * from './useCoinbasePrices' +export * from './useCveData' +export * from './useDShieldData' +export * from './useIssPosition' +export * from './useKevData' +export * from './useRansomwareData' +export * from './useSpaceWeather' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts new file mode 100644 index 00000000..da7a8e27 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts @@ -0,0 +1,25 @@ +// ©AngelaMos | 2026 +// useBgpHijackData.ts + +import { useEffect } from 'react' +import { isValidBgpHijack, type BgpHijack } from '@/api/types' +import { useSnapshot } from '@/api/snapshot' +import { useBgpHijackStore } from '@/stores/bgpHijack' + +interface BgpHijackData { + items: BgpHijack[] +} + +export function useBgpHijackData(): BgpHijackData { + const { data } = useSnapshot() + const items = useBgpHijackStore((s) => s.items) + const push = useBgpHijackStore((s) => s.push) + + const raw = data?.bgp_hijack + const seed = isValidBgpHijack(raw) ? raw : undefined + useEffect(() => { + if (seed) push(seed) + }, [seed, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts new file mode 100644 index 00000000..7e51373e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts @@ -0,0 +1,82 @@ +// ©AngelaMos | 2026 +// geo.types.ts + +import { z } from 'zod' + +export const issPositionSchema = z.object({ + latitude: z.number(), + longitude: z.number(), + altitude: z.number(), + velocity: z.number(), + timestamp: z.number(), + fetched_at: z.string().optional(), +}) + +export type IssPosition = z.infer + +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(), +}) + +export type EarthquakePayload = z.infer + +export const internetOutageSchema = z.object({ + id: z.string(), + startDate: z.string().optional(), + endDate: z.string().nullable().optional(), + locations: z.array(z.string()).optional(), + asns: z.array(z.number()).optional(), + reason: z.string().optional(), + outageType: z.string().optional(), +}) + +export type InternetOutage = z.infer + +export const bgpEnrichmentSchema = z.object({ + country: z.string().optional(), + abuse_confidence: z.number().optional(), + isp: z.string().optional(), + checked_ip: z.string().optional(), +}) + +export type BgpEnrichment = z.infer + +export const bgpHijackSchema = z.object({ + id: z.number(), + detectedAt: z.string().optional(), + startedAt: z.string().optional(), + duration: z.number().optional(), + confidenceScore: z.number().optional(), + hijackerAsn: z.number().optional(), + victimAsns: z.array(z.number()).nullable().optional(), + prefixes: z.array(z.string()).optional(), + enrichment: bgpEnrichmentSchema.optional(), +}) + +export type BgpHijack = z.infer + +export const isValidIssPosition = (data: unknown): data is IssPosition => { + if (data === null || data === undefined || typeof data !== 'object') return false + return issPositionSchema.safeParse(data).success +} + +export const isValidEarthquakePayload = ( + data: unknown +): data is EarthquakePayload => { + if (data === null || data === undefined || typeof data !== 'object') return false + return earthquakePayloadSchema.safeParse(data).success +} + +export const isValidInternetOutage = (data: unknown): data is InternetOutage => { + if (data === null || data === undefined || typeof data !== 'object') return false + return internetOutageSchema.safeParse(data).success +} + +export const isValidBgpHijack = (data: unknown): data is BgpHijack => { + if (data === null || data === undefined || typeof data !== 'object') return false + return bgpHijackSchema.safeParse(data).success +} 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 index 98719391..bbf97a0c 100644 --- 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 @@ -4,18 +4,36 @@ import { type QueryClient, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react' import { SNAPSHOT_KEY, type Snapshot, useSnapshot } from '@/api/snapshot' +import { + isValidBgpHijack, + isValidCoinbaseTick, + isValidCveEvent, + isValidEarthquakePayload, + isValidGdeltSpike, + isValidInternetOutage, + isValidIssPosition, + isValidKevEntry, + isValidRansomwareVictim, + isValidWikiItn, + type BgpHijack, + type CveEvent, + type EarthquakePayload, + type GdeltSpike, + type InternetOutage, + type IssPosition, + type KevEntry, + type RansomwareVictim, + type WikiItn, +} from '@/api/types' import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' import { unlockOnFirstGesture } from '@/lib/audio' import { getCentroid } from '@/lib/countryCentroids' -import { type CveEvent, useCveStore } from '@/stores/cve' +import { useBgpHijackStore } from '@/stores/bgpHijack' +import { useCveStore } from '@/stores/cve' import { useGlobeEvents } from '@/stores/globeEvents' -import { type KevEntry, useKevStore } from '@/stores/kev' +import { useKevStore } from '@/stores/kev' import { usePrices } from '@/stores/prices' -import { - type RansomwareVictim, - useRansomwareStore, - victimKey, -} from '@/stores/ransomware' +import { useRansomwareStore, victimKey } from '@/stores/ransomware' import { useTicker } from '@/stores/ticker' const ALL_TOPICS: readonly string[] = [ @@ -38,50 +56,6 @@ 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 -} - -interface OutagePayload { - id: string - startDate?: string - endDate?: string | null - locations?: string[] - asns?: number[] - reason?: string - outageType?: string -} - export function useDashboardLifecycle(): void { const { data: snapshot, isSuccess } = useSnapshot() const queryClient = useQueryClient() @@ -132,34 +106,36 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void { switch (ev.topic) { case 'cve_new': - handleCve(data as CveEvent) + if (isValidCveEvent(data)) handleCve(data) break case 'kev_added': - handleKev(data as KevEntry) + if (isValidKevEntry(data)) handleKev(data) break case 'ransomware_victim': - handleRansomware(data as RansomwareVictim) + if (isValidRansomwareVictim(data)) handleRansomware(data) break case 'coinbase_price': - handleCoinbase(data as CoinbaseTickPayload) + if (isValidCoinbaseTick(data)) handleCoinbase(data) break case 'earthquake': - handleEarthquake(data as EarthquakePayload) + if (isValidEarthquakePayload(data)) handleEarthquake(data) break case 'iss_position': - handleIss(data as IssPositionPayload, queryClient) + if (isValidIssPosition(data)) handleIss(data, queryClient) break case 'wiki_itn': - handleWiki(data as WikiItnPayload) + if (isValidWikiItn(data)) handleWiki(data) break case 'gdelt_spike': - handleGdelt(data as GdeltSpikePayload) + if (isValidGdeltSpike(data)) handleGdelt(data) break case 'internet_outage': - handleOutage(data as OutagePayload, queryClient) + if (isValidInternetOutage(data)) handleOutage(data, queryClient) + break + case 'bgp_hijack': + if (isValidBgpHijack(data)) handleHijack(data, queryClient) break case 'space_weather': - case 'bgp_hijack': case 'scan_firehose': mergeIntoSnapshot(queryClient, ev.topic, data) break @@ -169,17 +145,14 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void { } 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) pushRansomwarePoint(p) } @@ -198,13 +171,13 @@ function pushRansomwarePoint(p: RansomwareVictim): void { }) } -function handleOutage(p: OutagePayload, queryClient: QueryClient): void { +function handleOutage(p: InternetOutage, queryClient: QueryClient): void { pushOutagePoints(p) mergeIntoSnapshot(queryClient, 'internet_outage', p) } -function pushOutagePoints(p: OutagePayload): void { - if (!p.id || !Array.isArray(p.locations)) return +function pushOutagePoints(p: InternetOutage): void { + if (!Array.isArray(p.locations)) return const now = Date.now() for (const loc of p.locations) { const c = getCentroid(loc) @@ -220,8 +193,32 @@ function pushOutagePoints(p: OutagePayload): void { } } -function handleCoinbase(p: CoinbaseTickPayload): void { - if (!p.symbol || !p.ts) return +function handleHijack(p: BgpHijack, queryClient: QueryClient): void { + useBgpHijackStore.getState().push(p) + pushHijackPoint(p) + mergeIntoSnapshot(queryClient, 'bgp_hijack', p) +} + +function pushHijackPoint(p: BgpHijack): void { + const country = p.enrichment?.country + if (!country) return + const c = getCentroid(country) + if (!c) return + useGlobeEvents.getState().pushPoint({ + id: `hijack-${p.id}`, + type: 'hijack', + lat: c.lat, + lng: c.lng, + emittedAt: Date.now(), + meta: { + asn: p.hijackerAsn, + isp: p.enrichment?.isp, + prefixes: p.prefixes?.length, + }, + }) +} + +function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_24h?: string }): void { usePrices.getState().pushTick({ symbol: p.symbol, ts: new Date(p.ts).getTime(), @@ -254,8 +251,7 @@ function handleEarthquake(p: EarthquakePayload): void { }) } -function handleIss(p: IssPositionPayload, queryClient: QueryClient): void { - if (typeof p.latitude !== 'number' || typeof p.longitude !== 'number') return +function handleIss(p: IssPosition, queryClient: QueryClient): void { useGlobeEvents.getState().pushPoint({ id: 'iss-current', type: 'iss', @@ -266,7 +262,7 @@ function handleIss(p: IssPositionPayload, queryClient: QueryClient): void { mergeIntoSnapshot(queryClient, 'iss_position', p) } -function handleWiki(p: WikiItnPayload): void { +function handleWiki(p: WikiItn): void { if (!p.text) return const id = `wiki-${p.slug || p.text}` useTicker.getState().push({ @@ -277,8 +273,7 @@ function handleWiki(p: WikiItnPayload): void { }) } -function handleGdelt(p: GdeltSpikePayload): void { - if (!p.theme) return +function handleGdelt(p: GdeltSpike): void { useTicker.getState().push({ id: `gdelt-${p.theme}-${p.time}`, source: 'GDELT', @@ -299,8 +294,8 @@ function mergeIntoSnapshot( } function seedGlobeFromSnapshot(snap: Snapshot): void { - const eq = snap.earthquake as EarthquakePayload | undefined - if (eq) { + const eq = snap.earthquake + if (isValidEarthquakePayload(eq)) { const coords = eq.geometry?.coordinates if (Array.isArray(coords)) { const lng = coords[0] @@ -318,12 +313,8 @@ function seedGlobeFromSnapshot(snap: Snapshot): void { } } - const iss = snap.iss_position as IssPositionPayload | undefined - if ( - iss && - typeof iss.latitude === 'number' && - typeof iss.longitude === 'number' - ) { + const iss = snap.iss_position + if (isValidIssPosition(iss)) { useGlobeEvents.getState().pushPoint({ id: 'iss-current', type: 'iss', @@ -333,9 +324,15 @@ function seedGlobeFromSnapshot(snap: Snapshot): void { }) } - const rw = snap.ransomware_victim as RansomwareVictim | undefined - if (rw) pushRansomwarePoint(rw) + const rw = snap.ransomware_victim + if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw) - const outage = snap.internet_outage as OutagePayload | undefined - if (outage) pushOutagePoints(outage) + const outage = snap.internet_outage + if (isValidInternetOutage(outage)) pushOutagePoints(outage) + + const hijack = snap.bgp_hijack + if (isValidBgpHijack(hijack)) { + useBgpHijackStore.getState().push(hijack) + pushHijackPoint(hijack) + } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss new file mode 100644 index 00000000..1862d805 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// BGPHijackPanel.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); +} + +.asn { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + width: 18%; + color: var(--fg-1); +} + +.cc { + color: var(--fg-3); + width: 10%; +} + +.isp { + color: var(--fg-2); + width: 38%; +} + +.pfx { + font-family: var(--font-mono); + color: var(--fg-2); + width: 18%; +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 16%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx new file mode 100644 index 00000000..028b5214 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx @@ -0,0 +1,108 @@ +// ©AngelaMos | 2026 +// BGPHijackPanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useBgpHijackData } from '@/api/hooks' +import { Panel } from './Panel' +import styles from './BGPHijackPanel.module.scss' + +const HIJACK_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 BGPHijackPanel(): React.ReactElement { + const { items } = useBgpHijackData() + const seenIds = useRef>(new Set()) + const [flashIds, setFlashIds] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenIds.current.size === 0 + const newIds: number[] = [] + for (const h of items) { + if (!seenIds.current.has(h.id)) { + seenIds.current.add(h.id) + if (!isFirstSeed) newIds.push(h.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, HIJACK_ROW_LIMIT) + const now = Date.now() + + return ( + + + + + + + + + + + + + {recent.map((h) => { + const isFlashing = flashIds.has(h.id) + return ( + + + + + + + + ) + })} + +
ASNCCISPPfxAgo
{fmtAsn(h.hijackerAsn)}{h.enrichment?.country ?? '—'} + {h.enrichment?.isp ?? '—'} + + {fmtPfx(h.prefixes)} + {fmtAgo(h.detectedAt, now)}
+
+ ) +} + +BGPHijackPanel.displayName = 'BGPHijackPanel' + +function fmtAsn(asn: number | undefined): string { + if (!asn) return '—' + return `AS${asn}` +} + +function fmtPfx(prefixes: string[] | undefined): string { + if (!prefixes || prefixes.length === 0) return '—' + const first = prefixes[0] ?? '—' + if (prefixes.length === 1) return first + return `${first} +${prefixes.length - 1}` +} + +function fmtAgo(iso: string | undefined, now: number): string { + if (!iso || iso.startsWith('0001')) 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` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts new file mode 100644 index 00000000..d8b0d6a3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// bgpHijack.ts + +import { create } from 'zustand' +import { type BgpHijack } from '@/api/types' + +interface BgpHijackStore { + items: BgpHijack[] + push: (item: BgpHijack) => void + clear: () => void +} + +const BGP_HIJACK_CAP = 100 + +export const useBgpHijackStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.id !== item.id) + return { items: [item, ...filtered].slice(0, BGP_HIJACK_CAP) } + }), + clear: () => set({ items: [] }), +}))