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 ( + + + + + + + + + + + + + CVE + Sev + EPSS + Ago + + + + {recent.map((c) => ( + + {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: [] }), +}))