From 9d391434e0993f519ccc272ef072bb2371efe048 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 09:32:20 -0400 Subject: [PATCH] feat(monitor/frontend): ransomware panel (victim feed + 600ms data-changed flash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RansomwareStore (zustand) accumulates RansomwareVictim items dedup-by composite key (post_title|group_name|discovered) — the JSON payload has no id field (the backend computes one via Row.ID() but doesn't include it in the WS payload), so the composite key is what we have. Capped 200. 6-row tight table — victim mono primary, group sans --fg-2, country sans --fg-3, ago mono muted right-aligned. table-layout fixed + ellipsis. Hover --bg-row-hover. Flash on new arrival: a useRef tracks seen composite keys; new items not in the set get added to a flashKeys state for 600ms, which applies a row-flash keyframe animation that fades from --fg-4 background to transparent. Per ethos motion=meaning rule — the only panel motion allowed because new ransomware victims literally are "data changed." Initial seed (when seenKeys is empty) does NOT trigger a flash — the first batch is "what's there now," not "new arrivals." No skull / lock / "RANSOMWARE!!" iconography (per Plan 5 Task 17 Design QA). No colored severity. After flash, row visually identical to others. Real shape verified against live snapshot.ransomware_victim: snake_case post_title/group_name/discovered/country/activity/website/description. TS interface matches. --- .../src/pages/dashboard/Dashboard.tsx | 2 + .../pages/panels/RansomwarePanel.module.scss | 69 ++++++++++++ .../src/pages/panels/RansomwarePanel.tsx | 105 ++++++++++++++++++ .../frontend/src/stores/ransomware.ts | 37 ++++++ 4 files changed, 213 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.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 8e2a998f..37657844 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 @@ -5,6 +5,7 @@ import { Globe } from '@/pages/globe/Globe' import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel' import { DShieldPanel } from '@/pages/panels/DShieldPanel' import { KEVPanel } from '@/pages/panels/KEVPanel' +import { RansomwarePanel } from '@/pages/panels/RansomwarePanel' import { useUIStore } from '@/stores/ui' import { AlertBanner } from './AlertBanner' import { BottomTicker } from './BottomTicker' @@ -32,6 +33,7 @@ export function Dashboard(): React.ReactElement { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss new file mode 100644 index 00000000..55f81358 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss @@ -0,0 +1,69 @@ +// ©AngelaMos | 2026 +// RansomwarePanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table 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; +} + +.table 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; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.victim { + font-family: var(--font-mono); + width: 50%; +} + +.group { + color: var(--fg-2); + width: 25%; +} + +.cc { + color: var(--fg-3); + width: 12%; +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 13%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx new file mode 100644 index 00000000..9c09c6b4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx @@ -0,0 +1,105 @@ +// ©AngelaMos | 2026 +// RansomwarePanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useSnapshot } from '@/api/snapshot' +import { + type RansomwareVictim, + useRansomwareStore, + victimKey, +} from '@/stores/ransomware' +import { Panel } from './Panel' +import styles from './RansomwarePanel.module.scss' + +const VICTIM_ROW_LIMIT = 6 +const FLASH_DURATION_MS = 600 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function RansomwarePanel(): React.ReactElement { + const { data } = useSnapshot() + const items = useRansomwareStore((s) => s.items) + const push = useRansomwareStore((s) => s.push) + + const seed = data?.ransomware_victim as RansomwareVictim | undefined + useEffect(() => { + if (seed?.post_title) push(seed) + }, [seed, push]) + + const seenKeys = useRef>(new Set()) + const [flashKeys, setFlashKeys] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenKeys.current.size === 0 + const newKeys: string[] = [] + for (const v of items) { + const k = victimKey(v) + if (!seenKeys.current.has(k)) { + seenKeys.current.add(k) + if (!isFirstSeed) newKeys.push(k) + } + } + if (newKeys.length > 0) setFlashKeys(new Set(newKeys)) + }, [items]) + + useEffect(() => { + if (flashKeys.size === 0) return + const t = setTimeout(() => setFlashKeys(new Set()), FLASH_DURATION_MS) + return () => clearTimeout(t) + }, [flashKeys]) + + const recent = items.slice(0, VICTIM_ROW_LIMIT) + const now = Date.now() + + return ( + + + + + + + + + + + + {recent.map((v) => { + const key = victimKey(v) + const isFlashing = flashKeys.has(key) + return ( + + + + + + + ) + })} + +
VictimGroupCCAgo
+ {v.post_title} + {v.group_name}{v.country ?? '—'}{fmtAgo(v.discovered, now)}
+
+ ) +} + +RansomwarePanel.displayName = 'RansomwarePanel' + +function fmtAgo(iso: string, now: number): string { + const t = new Date(iso).getTime() + if (!Number.isFinite(t)) return '—' + const diff = now - t + if (diff < MS_PER_HOUR) { + return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m` + } + if (diff < HOURS_PER_DAY * MS_PER_HOUR) { + return `${Math.floor(diff / MS_PER_HOUR)}h` + } + return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts new file mode 100644 index 00000000..eb60775c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts @@ -0,0 +1,37 @@ +// ©AngelaMos | 2026 +// ransomware.ts + +import { create } from 'zustand' + +export interface RansomwareVictim { + post_title: string + group_name: string + discovered: string + country?: string + activity?: string + website?: string + description?: string +} + +interface RansomwareStore { + items: RansomwareVictim[] + push: (item: RansomwareVictim) => void + clear: () => void +} + +const RANSOMWARE_CAP = 200 + +export function victimKey(v: RansomwareVictim): string { + return `${v.post_title}|${v.group_name}|${v.discovered}` +} + +export const useRansomwareStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const key = victimKey(item) + const filtered = s.items.filter((i) => victimKey(i) !== key) + return { items: [item, ...filtered].slice(0, RANSOMWARE_CAP) } + }), + clear: () => set({ items: [] }), +}))