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: [] }), +}))