fix(monitor/frontend): DShield panel matches actual snapshot.scan_firehose shape

The panel crashed at runtime with "(ds.topports ?? []).slice is not a
function" because Plan 5 assumed shapes that didn't match what the Phase 2
DShield collector actually emits. Reality:

  topports: Record<string, Port>      // dict keyed "0".."N", not Array
  port field: targetport              // not "port"
  topips[].source                     // the IP — not "ip"
  topips[].reports                    // the count — not "records"
  topips                              // has no country/CC field
  dailysummary[].date                 // ISO date string, ascending order

toArray() helper accepts both Record<string,T> and T[] defensively (so
either shape works if the backend changes its mind). Both lists sort by
rank before slicing. The CC column is dropped (data doesn't carry it);
its slot becomes the per-source target count which the data does carry.
Daily summary picks the latest entry by date instead of index 0 (the
order is ascending, so [0] would be the oldest day).

Bigger discovery: most snapshot keys (cve_new / kev_added /
ransomware_victim / coinbase_price / etc.) are LATEST-event payloads,
not the recent-list arrays Plan 5 anticipated. Tasks 15-21 will need
per-topic Zustand stores that accumulate over time, populated by Task 24
from snapshot bootstrap + WS messages. Will discuss with Carter before
committing to that architecture.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:12:36 -04:00
parent a91f290595
commit d01fc57989
1 changed files with 52 additions and 19 deletions

View File

@ -11,24 +11,29 @@ const THOUSAND = 1_000
const MILLION = 1_000_000 const MILLION = 1_000_000
interface DShieldPort { interface DShieldPort {
port: number rank: number
targetport: number
records: number records: number
targets: number
sources: number
} }
interface DShieldSource { interface DShieldSource {
ip: string rank: number
country?: string source: string
records: number reports: number
targets: number
} }
interface DShieldDailySummary { interface DShieldDailySummary {
date: string
records: number records: number
sources: number sources: number
targets: number targets: number
} }
interface DShieldData { interface DShieldData {
topports?: DShieldPort[] topports?: Record<string, DShieldPort> | DShieldPort[]
topips?: DShieldSource[] topips?: DShieldSource[]
dailysummary?: DShieldDailySummary[] dailysummary?: DShieldDailySummary[]
} }
@ -36,7 +41,18 @@ interface DShieldData {
export function DShieldPanel(): React.ReactElement { export function DShieldPanel(): React.ReactElement {
const { data } = useSnapshot() const { data } = useSnapshot()
const ds = (data?.scan_firehose as DShieldData | undefined) ?? {} const ds = (data?.scan_firehose as DShieldData | undefined) ?? {}
const summary = ds.dailysummary?.[0]
const ports = toArray(ds.topports)
.slice()
.sort((a, b) => a.rank - b.rank)
.slice(0, PORT_ROW_LIMIT)
const sources = (ds.topips ?? [])
.slice()
.sort((a, b) => a.rank - b.rank)
.slice(0, SOURCE_ROW_LIMIT)
const summary = pickLatestSummary(ds.dailysummary)
return ( return (
<Panel <Panel
@ -50,14 +66,16 @@ export function DShieldPanel(): React.ReactElement {
<thead> <thead>
<tr> <tr>
<th>Port</th> <th>Port</th>
<th>Hits / 24h</th> <th>Hits</th>
<th>Src</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(ds.topports ?? []).slice(0, PORT_ROW_LIMIT).map((p) => ( {ports.map((p) => (
<tr key={p.port}> <tr key={p.targetport}>
<td className={styles.mono}>{p.port}</td> <td className={styles.mono}>{p.targetport}</td>
<td className={styles.mono}>{p.records.toLocaleString()}</td> <td className={styles.mono}>{fmtN(p.records)}</td>
<td className={styles.mono}>{fmtN(p.sources)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -67,16 +85,16 @@ export function DShieldPanel(): React.ReactElement {
<thead> <thead>
<tr> <tr>
<th>Source IP</th> <th>Source IP</th>
<th>CC</th> <th>Reports</th>
<th>Hits</th> <th>Tgt</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(ds.topips ?? []).slice(0, SOURCE_ROW_LIMIT).map((s) => ( {sources.map((s) => (
<tr key={s.ip}> <tr key={s.source}>
<td className={styles.mono}>{s.ip}</td> <td className={styles.mono}>{s.source}</td>
<td>{s.country ?? '—'}</td> <td className={styles.mono}>{fmtN(s.reports)}</td>
<td className={styles.mono}>{s.records.toLocaleString()}</td> <td className={styles.mono}>{fmtN(s.targets)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -86,7 +104,7 @@ export function DShieldPanel(): React.ReactElement {
{summary && ( {summary && (
<p className={styles.summary}> <p className={styles.summary}>
{fmtN(summary.records)} records · {fmtN(summary.sources)} sources ·{' '} {fmtN(summary.records)} records · {fmtN(summary.sources)} sources ·{' '}
{fmtN(summary.targets)} targets last 24h {fmtN(summary.targets)} targets {summary.date}
</p> </p>
)} )}
</Panel> </Panel>
@ -95,6 +113,21 @@ export function DShieldPanel(): React.ReactElement {
DShieldPanel.displayName = 'DShieldPanel' DShieldPanel.displayName = 'DShieldPanel'
function toArray<T>(v: Record<string, T> | T[] | undefined): T[] {
if (!v) return []
if (Array.isArray(v)) return v
return Object.values(v)
}
function pickLatestSummary(
list: DShieldDailySummary[] | undefined
): DShieldDailySummary | undefined {
if (!list || list.length === 0) return undefined
return list.reduce((latest, entry) =>
entry.date > latest.date ? entry : latest
)
}
function fmtN(n: number): string { function fmtN(n: number): string {
if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M` if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M`
if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(1)}k` if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(1)}k`