feat(monitor/frontend): bgp hijack panel + globe plot via abuseipdb-enriched country
This commit is contained in:
parent
c25fd57eaf
commit
0f53e67236
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<typeof issPositionSchema>
|
||||
|
||||
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<typeof earthquakePayloadSchema>
|
||||
|
||||
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<typeof internetOutageSchema>
|
||||
|
||||
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<typeof bgpEnrichmentSchema>
|
||||
|
||||
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<typeof bgpHijackSchema>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Set<number>>(new Set())
|
||||
const [flashIds, setFlashIds] = useState<Set<number>>(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 (
|
||||
<Panel
|
||||
title="BGP"
|
||||
subtitle="HIJACKS"
|
||||
rawHref="https://radar.cloudflare.com/security"
|
||||
rawLabel="Cloudflare Radar"
|
||||
>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.asn}>ASN</th>
|
||||
<th className={styles.cc}>CC</th>
|
||||
<th className={styles.isp}>ISP</th>
|
||||
<th className={styles.pfx}>Pfx</th>
|
||||
<th className={styles.ago}>Ago</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((h) => {
|
||||
const isFlashing = flashIds.has(h.id)
|
||||
return (
|
||||
<tr key={h.id} className={isFlashing ? styles.flash : undefined}>
|
||||
<td className={styles.asn}>{fmtAsn(h.hijackerAsn)}</td>
|
||||
<td className={styles.cc}>{h.enrichment?.country ?? '—'}</td>
|
||||
<td className={styles.isp} title={h.enrichment?.isp ?? ''}>
|
||||
{h.enrichment?.isp ?? '—'}
|
||||
</td>
|
||||
<td className={styles.pfx} title={(h.prefixes ?? []).join(', ')}>
|
||||
{fmtPfx(h.prefixes)}
|
||||
</td>
|
||||
<td className={styles.ago}>{fmtAgo(h.detectedAt, now)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
|
@ -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<BgpHijackStore>((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: [] }),
|
||||
}))
|
||||
Loading…
Reference in New Issue