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