feat(monitor/frontend): DShield panel (top ports + top source IPs, dense tables)

Two side-by-side tables fed from snapshot.scan_firehose: Top Ports (port +
24h hits) and Top Sources (IP + CC + hits). Below them a one-line muted
mono summary "Xk records · Yk sources · Z targets — last 24h." Tables, not
bar charts — operator wants to read the port number 22 / 3389, not stare
at a horizontal bar.

Port and IP and hit counts are mono with tabular-nums (digit alignment
matters). Country code stays sans (text label, not a number). Hover row
background var(--bg-row-hover) — subtle, no transform / no transition /
no theatre. Defensive empty-state — when snapshot has no scan_firehose
yet, tables render as empty (per ethos: no "no data" message).

PORT_ROW_LIMIT / SOURCE_ROW_LIMIT / THOUSAND / MILLION constants at module
scope (no inline magic). Renders in the right column as the bottom panel
per spec §11.1 column ordering.
This commit is contained in:
CarterPerez-dev 2026-05-03 07:15:16 -04:00
parent 44f0ff3c3d
commit a91f290595
3 changed files with 154 additions and 1 deletions

View File

@ -2,6 +2,7 @@
// Dashboard.tsx
import { Globe } from '@/pages/globe/Globe'
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
import { useUIStore } from '@/stores/ui'
import { AlertBanner } from './AlertBanner'
import { BottomTicker } from './BottomTicker'
@ -26,7 +27,9 @@ export function Dashboard(): React.ReactElement {
<section className={styles.center}>
<Globe />
</section>
<aside className={styles.right} />
<aside className={styles.right}>
<DShieldPanel />
</aside>
</main>
<BottomTicker />
</div>

View File

@ -0,0 +1,48 @@
// ©AngelaMos | 2026
// DShieldPanel.module.scss
.row {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 12px;
padding: 4px 8px;
}
.table {
width: 100%;
border-collapse: collapse;
}
.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);
}
.table tbody tr:hover {
background: var(--bg-row-hover);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.summary {
margin: 8px;
font-size: var(--type-body);
color: var(--fg-3);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}

View File

@ -0,0 +1,102 @@
// ©AngelaMos | 2026
// DShieldPanel.tsx
import { useSnapshot } from '@/api/snapshot'
import styles from './DShieldPanel.module.scss'
import { Panel } from './Panel'
const PORT_ROW_LIMIT = 8
const SOURCE_ROW_LIMIT = 8
const THOUSAND = 1_000
const MILLION = 1_000_000
interface DShieldPort {
port: number
records: number
}
interface DShieldSource {
ip: string
country?: string
records: number
}
interface DShieldDailySummary {
records: number
sources: number
targets: number
}
interface DShieldData {
topports?: DShieldPort[]
topips?: DShieldSource[]
dailysummary?: DShieldDailySummary[]
}
export function DShieldPanel(): React.ReactElement {
const { data } = useSnapshot()
const ds = (data?.scan_firehose as DShieldData | undefined) ?? {}
const summary = ds.dailysummary?.[0]
return (
<Panel
title="DSHIELD"
subtitle="MASS SCAN"
rawHref="https://isc.sans.edu/api/"
rawLabel="DShield API"
>
<div className={styles.row}>
<table className={styles.table}>
<thead>
<tr>
<th>Port</th>
<th>Hits / 24h</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>
</tr>
))}
</tbody>
</table>
<table className={styles.table}>
<thead>
<tr>
<th>Source IP</th>
<th>CC</th>
<th>Hits</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>
</tr>
))}
</tbody>
</table>
</div>
{summary && (
<p className={styles.summary}>
{fmtN(summary.records)} records · {fmtN(summary.sources)} sources ·{' '}
{fmtN(summary.targets)} targets last 24h
</p>
)}
</Panel>
)
}
DShieldPanel.displayName = 'DShieldPanel'
function fmtN(n: number): string {
if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M`
if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(1)}k`
return String(n)
}