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:
parent
44f0ff3c3d
commit
a91f290595
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue