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:
parent
9ea40198d0
commit
79cbdaaa51
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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: [] }),
|
||||
}))
|
||||
Loading…
Reference in New Issue