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:
parent
a91f290595
commit
d01fc57989
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue