feat(monitor/frontend): earthquake + outage panels (left + right column, USGS / CF Radar)
This commit is contained in:
parent
887dc217d2
commit
6f062bf49b
|
|
@ -10,7 +10,9 @@ export * from './useBgpHijackData'
|
||||||
export * from './useCoinbasePrices'
|
export * from './useCoinbasePrices'
|
||||||
export * from './useCveData'
|
export * from './useCveData'
|
||||||
export * from './useDShieldData'
|
export * from './useDShieldData'
|
||||||
|
export * from './useEarthquakeData'
|
||||||
export * from './useIssPosition'
|
export * from './useIssPosition'
|
||||||
export * from './useKevData'
|
export * from './useKevData'
|
||||||
|
export * from './useOutageData'
|
||||||
export * from './useRansomwareData'
|
export * from './useRansomwareData'
|
||||||
export * from './useSpaceWeather'
|
export * from './useSpaceWeather'
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,25 @@ export const issPositionSchema = z.object({
|
||||||
|
|
||||||
export type IssPosition = z.infer<typeof issPositionSchema>
|
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({
|
export const earthquakePayloadSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
geometry: z
|
geometry: z
|
||||||
.object({ coordinates: z.array(z.number()).optional() })
|
.object({ coordinates: z.array(z.number()).optional() })
|
||||||
.optional(),
|
.optional(),
|
||||||
properties: z.record(z.string(), z.unknown()).optional(),
|
properties: earthquakePropertiesSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type EarthquakePayload = z.infer<typeof earthquakePayloadSchema>
|
export type EarthquakePayload = z.infer<typeof earthquakePayloadSchema>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ import { BGPHijackPanel } from '@/pages/panels/BGPHijackPanel'
|
||||||
import { BTCPanel } from '@/pages/panels/BTCPanel'
|
import { BTCPanel } from '@/pages/panels/BTCPanel'
|
||||||
import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel'
|
import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel'
|
||||||
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
|
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
|
||||||
|
import { EarthquakePanel } from '@/pages/panels/EarthquakePanel'
|
||||||
import { ETHPanel } from '@/pages/panels/ETHPanel'
|
import { ETHPanel } from '@/pages/panels/ETHPanel'
|
||||||
import { ISSPanel } from '@/pages/panels/ISSPanel'
|
import { ISSPanel } from '@/pages/panels/ISSPanel'
|
||||||
import { KEVPanel } from '@/pages/panels/KEVPanel'
|
import { KEVPanel } from '@/pages/panels/KEVPanel'
|
||||||
|
import { OutagePanel } from '@/pages/panels/OutagePanel'
|
||||||
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
|
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
|
||||||
import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel'
|
import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
|
@ -39,6 +41,7 @@ export function Dashboard(): React.ReactElement {
|
||||||
<ETHPanel />
|
<ETHPanel />
|
||||||
<SpaceWeatherPanel />
|
<SpaceWeatherPanel />
|
||||||
<ISSPanel />
|
<ISSPanel />
|
||||||
|
<EarthquakePanel />
|
||||||
<CVEVelocityPanel />
|
<CVEVelocityPanel />
|
||||||
</aside>
|
</aside>
|
||||||
<section className={styles.center}>
|
<section className={styles.center}>
|
||||||
|
|
@ -46,6 +49,7 @@ export function Dashboard(): React.ReactElement {
|
||||||
</section>
|
</section>
|
||||||
<aside className={styles.right}>
|
<aside className={styles.right}>
|
||||||
<BGPHijackPanel />
|
<BGPHijackPanel />
|
||||||
|
<OutagePanel />
|
||||||
<KEVPanel />
|
<KEVPanel />
|
||||||
<RansomwarePanel />
|
<RansomwarePanel />
|
||||||
<DShieldPanel />
|
<DShieldPanel />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
isValidBgpHijack,
|
isValidBgpHijack,
|
||||||
isValidCoinbaseTick,
|
isValidCoinbaseTick,
|
||||||
isValidCveEvent,
|
isValidCveEvent,
|
||||||
|
isValidDShieldData,
|
||||||
isValidEarthquakePayload,
|
isValidEarthquakePayload,
|
||||||
isValidGdeltSpike,
|
isValidGdeltSpike,
|
||||||
isValidInternetOutage,
|
isValidInternetOutage,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
isValidWikiItn,
|
isValidWikiItn,
|
||||||
type BgpHijack,
|
type BgpHijack,
|
||||||
type CveEvent,
|
type CveEvent,
|
||||||
|
type DShieldData,
|
||||||
type EarthquakePayload,
|
type EarthquakePayload,
|
||||||
type GdeltSpike,
|
type GdeltSpike,
|
||||||
type InternetOutage,
|
type InternetOutage,
|
||||||
|
|
@ -30,8 +32,10 @@ import { unlockOnFirstGesture } from '@/lib/audio'
|
||||||
import { getCentroid } from '@/lib/countryCentroids'
|
import { getCentroid } from '@/lib/countryCentroids'
|
||||||
import { useBgpHijackStore } from '@/stores/bgpHijack'
|
import { useBgpHijackStore } from '@/stores/bgpHijack'
|
||||||
import { useCveStore } from '@/stores/cve'
|
import { useCveStore } from '@/stores/cve'
|
||||||
|
import { useEarthquakeStore } from '@/stores/earthquake'
|
||||||
import { useGlobeEvents } from '@/stores/globeEvents'
|
import { useGlobeEvents } from '@/stores/globeEvents'
|
||||||
import { useKevStore } from '@/stores/kev'
|
import { useKevStore } from '@/stores/kev'
|
||||||
|
import { useOutageStore } from '@/stores/outage'
|
||||||
import { usePrices } from '@/stores/prices'
|
import { usePrices } from '@/stores/prices'
|
||||||
import { useRansomwareStore, victimKey } from '@/stores/ransomware'
|
import { useRansomwareStore, victimKey } from '@/stores/ransomware'
|
||||||
import { useTicker } from '@/stores/ticker'
|
import { useTicker } from '@/stores/ticker'
|
||||||
|
|
@ -101,10 +105,10 @@ export function useDashboardLifecycle(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
|
function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
|
||||||
const data = ev.payload
|
const data = ev.data
|
||||||
if (data === undefined || data === null) return
|
if (data === undefined || data === null) return
|
||||||
|
|
||||||
switch (ev.topic) {
|
switch (ev.ch) {
|
||||||
case 'cve_new':
|
case 'cve_new':
|
||||||
if (isValidCveEvent(data)) handleCve(data)
|
if (isValidCveEvent(data)) handleCve(data)
|
||||||
break
|
break
|
||||||
|
|
@ -135,9 +139,11 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
|
||||||
case 'bgp_hijack':
|
case 'bgp_hijack':
|
||||||
if (isValidBgpHijack(data)) handleHijack(data, queryClient)
|
if (isValidBgpHijack(data)) handleHijack(data, queryClient)
|
||||||
break
|
break
|
||||||
case 'space_weather':
|
|
||||||
case 'scan_firehose':
|
case 'scan_firehose':
|
||||||
mergeIntoSnapshot(queryClient, ev.topic, data)
|
if (isValidDShieldData(data)) handleScanFirehose(data, queryClient)
|
||||||
|
break
|
||||||
|
case 'space_weather':
|
||||||
|
mergeIntoSnapshot(queryClient, ev.ch, data)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
@ -172,6 +178,7 @@ function pushRansomwarePoint(p: RansomwareVictim): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOutage(p: InternetOutage, queryClient: QueryClient): void {
|
function handleOutage(p: InternetOutage, queryClient: QueryClient): void {
|
||||||
|
useOutageStore.getState().push(p)
|
||||||
pushOutagePoints(p)
|
pushOutagePoints(p)
|
||||||
mergeIntoSnapshot(queryClient, 'internet_outage', 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 {
|
function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_24h?: string }): void {
|
||||||
usePrices.getState().pushTick({
|
usePrices.getState().pushTick({
|
||||||
symbol: p.symbol,
|
symbol: p.symbol,
|
||||||
|
|
@ -228,6 +257,7 @@ function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_2
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEarthquake(p: EarthquakePayload): void {
|
function handleEarthquake(p: EarthquakePayload): void {
|
||||||
|
useEarthquakeStore.getState().push(p)
|
||||||
const coords = p.geometry?.coordinates
|
const coords = p.geometry?.coordinates
|
||||||
if (!Array.isArray(coords)) return
|
if (!Array.isArray(coords)) return
|
||||||
const lng = coords[0]
|
const lng = coords[0]
|
||||||
|
|
@ -296,6 +326,7 @@ function mergeIntoSnapshot(
|
||||||
function seedGlobeFromSnapshot(snap: Snapshot): void {
|
function seedGlobeFromSnapshot(snap: Snapshot): void {
|
||||||
const eq = snap.earthquake
|
const eq = snap.earthquake
|
||||||
if (isValidEarthquakePayload(eq)) {
|
if (isValidEarthquakePayload(eq)) {
|
||||||
|
useEarthquakeStore.getState().push(eq)
|
||||||
const coords = eq.geometry?.coordinates
|
const coords = eq.geometry?.coordinates
|
||||||
if (Array.isArray(coords)) {
|
if (Array.isArray(coords)) {
|
||||||
const lng = coords[0]
|
const lng = coords[0]
|
||||||
|
|
@ -328,11 +359,17 @@ function seedGlobeFromSnapshot(snap: Snapshot): void {
|
||||||
if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw)
|
if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw)
|
||||||
|
|
||||||
const outage = snap.internet_outage
|
const outage = snap.internet_outage
|
||||||
if (isValidInternetOutage(outage)) pushOutagePoints(outage)
|
if (isValidInternetOutage(outage)) {
|
||||||
|
useOutageStore.getState().push(outage)
|
||||||
|
pushOutagePoints(outage)
|
||||||
|
}
|
||||||
|
|
||||||
const hijack = snap.bgp_hijack
|
const hijack = snap.bgp_hijack
|
||||||
if (isValidBgpHijack(hijack)) {
|
if (isValidBgpHijack(hijack)) {
|
||||||
useBgpHijackStore.getState().push(hijack)
|
useBgpHijackStore.getState().push(hijack)
|
||||||
pushHijackPoint(hijack)
|
pushHijackPoint(hijack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scan = snap.scan_firehose
|
||||||
|
if (isValidDShieldData(scan)) pushScanPoints(scan)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`
|
||||||
|
}
|
||||||
|
|
@ -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: [] }),
|
||||||
|
}))
|
||||||
|
|
@ -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: [] }),
|
||||||
|
}))
|
||||||
Loading…
Reference in New Issue