From 79cbdaaa518d0d65477fcc4dd7a29e4f8058d748 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 09:20:58 -0400 Subject: [PATCH] feat(monitor/frontend): CVE velocity panel (1h/6h/24h KPIs + 24h sparkline + recent table) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/pages/dashboard/Dashboard.tsx | 2 + .../pages/panels/CVEVelocityPanel.module.scss | 72 +++++++++ .../src/pages/panels/CVEVelocityPanel.tsx | 139 ++++++++++++++++++ .../frontend/src/stores/cve.ts | 33 +++++ 4 files changed, 246 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx index e865c673..6f449d57 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx @@ -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 { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss new file mode 100644 index 00000000..f3a2cc26 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss @@ -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; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx new file mode 100644 index 00000000..f0390906 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx @@ -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 ( + +
+ + + +
+
+ +
+ + + + + + + + + + + {recent.map((c) => ( + + + + + + + ))} + +
CVESevEPSSAgo
{c.CveID}{c.Severity}{fmtEpss(c.EPSSPercentile)}{fmtAgo(c.Published, now)}
+
+ ) +} + +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(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` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts new file mode 100644 index 00000000..7a7a4a17 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts @@ -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((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: [] }), +}))