feat(monitor/frontend): CVE velocity panel (1h/6h/24h KPIs + 24h sparkline + recent table)

CveStore (zustand) accumulates CveEvent items dedup-by-CveID, capped at
500. The panel seeds the store from snapshot.cve_new on mount via
useEffect — Task 24's WS routing will extend this with live deltas.

Three KPIs derived in-component from the store (1h/6h/24h counts, mono
tabular --type-num-l). 24h sparkline computed from per-hour bucketing
of items[].Published (one of the few places a chart is justified —
shape-of-CVE-velocity IS the insight per ethos rule on charts). Bottom
table shows the 5 most recent: CVE-ID mono, severity sans plain (NOT a
colored badge — severity stays monochrome per ethos), EPSS percentile
mono right-aligned, relative-time mono muted right-aligned. table-layout:
fixed + per-column widths + ellipsis to keep rows on a single line in
the 320px column.

Real CveEvent shape matches the backend Row struct (verified against
live snapshot): PascalCase keys CveID/Published/LastModified/Severity/
CVSS/EPSSScore/EPSSPercentile/InKEV. Earlier plan-author assumption of
{recent_24h:[]} was wrong — snapshot delivers a single latest event,
the running list builds in the store over time.

Plan 5 Task 15 Design QA: KPIs mono tabular, sparkline pure stroke
(currentColor, no fill/axes), severity NOT a colored badge, EPSS mono %,
ago muted, no "view all" CTA — operator clicks NVD via the raw link.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:20:58 -04:00
parent 9ea40198d0
commit 79cbdaaa51
4 changed files with 246 additions and 0 deletions

View File

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

View File

@ -0,0 +1,72 @@
// ©AngelaMos | 2026
// CVEVelocityPanel.module.scss
.kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid var(--fg-4);
}
.spark {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 8px;
border-bottom: 1px solid var(--fg-4);
color: var(--fg-2);
}
.recent {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.recent 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;
}
.recent tbody td {
padding: var(--row-py) var(--row-px);
font-size: var(--type-body);
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent tbody tr:hover {
background: var(--bg-row-hover);
}
.cveId {
font-family: var(--font-mono);
width: 38%;
}
.severity {
width: 22%;
color: var(--fg-2);
}
.epss {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 18%;
text-align: right;
}
.ago {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 22%;
color: var(--fg-3);
text-align: right;
}

View File

@ -0,0 +1,139 @@
// ©AngelaMos | 2026
// CVEVelocityPanel.tsx
import { useEffect, useMemo } from 'react'
import { useSnapshot } from '@/api/snapshot'
import { type CveEvent, useCveStore } from '@/stores/cve'
import styles from './CVEVelocityPanel.module.scss'
import { Panel } from './Panel'
import { KPI } from './shared/KPI'
import { Sparkline } from './shared/Sparkline'
const RECENT_ROW_LIMIT = 5
const SPARKLINE_HOURS = 24
const SPARKLINE_WIDTH = 280
const SPARKLINE_HEIGHT = 28
const MS_PER_HOUR = 3_600_000
const MS_PER_MINUTE = 60_000
const HOURS_PER_DAY = 24
export function CVEVelocityPanel(): React.ReactElement {
const { data } = useSnapshot()
const items = useCveStore((s) => s.items)
const push = useCveStore((s) => s.push)
const seed = data?.cve_new as CveEvent | undefined
useEffect(() => {
if (seed?.CveID) push(seed)
}, [seed, push])
const now = Date.now()
const counts = useMemo(() => {
return {
h1: countWithin(items, 1, now),
h6: countWithin(items, 6, now),
h24: countWithin(items, SPARKLINE_HOURS, now),
}
}, [items, now])
const hourly = useMemo(
() => hourlyBuckets(items, SPARKLINE_HOURS, now),
[items, now]
)
const recent = items.slice(0, RECENT_ROW_LIMIT)
return (
<Panel
title="CVE"
subtitle="VELOCITY + EPSS"
rawHref="https://nvd.nist.gov/"
rawLabel="NVD CVE 2.0"
>
<div className={styles.kpis}>
<KPI label="1H" value={counts.h1} />
<KPI label="6H" value={counts.h6} />
<KPI label="24H" value={counts.h24} />
</div>
<div className={styles.spark}>
<Sparkline
data={hourly}
width={SPARKLINE_WIDTH}
height={SPARKLINE_HEIGHT}
/>
</div>
<table className={styles.recent}>
<thead>
<tr>
<th className={styles.cveId}>CVE</th>
<th className={styles.severity}>Sev</th>
<th className={styles.epss}>EPSS</th>
<th className={styles.ago}>Ago</th>
</tr>
</thead>
<tbody>
{recent.map((c) => (
<tr key={c.CveID}>
<td className={styles.cveId}>{c.CveID}</td>
<td className={styles.severity}>{c.Severity}</td>
<td className={styles.epss}>{fmtEpss(c.EPSSPercentile)}</td>
<td className={styles.ago}>{fmtAgo(c.Published, now)}</td>
</tr>
))}
</tbody>
</table>
</Panel>
)
}
CVEVelocityPanel.displayName = 'CVEVelocityPanel'
function countWithin(items: CveEvent[], hours: number, now: number): number {
const cutoff = now - hours * MS_PER_HOUR
return items.reduce((acc, item) => {
const ts = parseTs(item.Published)
return ts !== null && ts >= cutoff ? acc + 1 : acc
}, 0)
}
function hourlyBuckets(items: CveEvent[], hours: number, now: number): number[] {
const buckets = new Array<number>(hours).fill(0)
for (const item of items) {
const ts = parseTs(item.Published)
if (ts === null) continue
const hoursAgo = Math.floor((now - ts) / MS_PER_HOUR)
if (hoursAgo >= 0 && hoursAgo < hours) {
const idx = hours - 1 - hoursAgo
buckets[idx] = (buckets[idx] ?? 0) + 1
}
}
return buckets
}
function parseTs(iso: string): number | null {
const t = new Date(iso).getTime()
return Number.isFinite(t) ? t : null
}
function fmtEpss(percentile: number | null): string {
if (percentile === null) return '—'
return `${(percentile * 100).toFixed(1)}%`
}
function fmtAgo(iso: string, now: number): string {
const t = parseTs(iso)
if (t === null) return '—'
const diffMs = now - t
if (diffMs < MS_PER_HOUR) {
const m = Math.floor(diffMs / MS_PER_MINUTE)
return `${Math.max(m, 0)}m`
}
if (diffMs < HOURS_PER_DAY * MS_PER_HOUR) {
const h = Math.floor(diffMs / MS_PER_HOUR)
const mins = Math.floor((diffMs % MS_PER_HOUR) / MS_PER_MINUTE)
return mins > 0 ? `${h}h${mins}m` : `${h}h`
}
const d = Math.floor(diffMs / (HOURS_PER_DAY * MS_PER_HOUR))
return `${d}d`
}

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// cve.ts
import { create } from 'zustand'
export interface CveEvent {
CveID: string
Published: string
LastModified: string
Severity: string
CVSS: number | null
EPSSScore: number | null
EPSSPercentile: number | null
InKEV?: boolean
}
interface CveStore {
items: CveEvent[]
push: (item: CveEvent) => void
clear: () => void
}
const CVE_CAP = 500
export const useCveStore = create<CveStore>((set) => ({
items: [],
push: (item) =>
set((s) => {
const filtered = s.items.filter((i) => i.CveID !== item.CveID)
return { items: [item, ...filtered].slice(0, CVE_CAP) }
}),
clear: () => set({ items: [] }),
}))