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