feat(monitor/frontend): ransomware panel (victim feed + 600ms data-changed flash)

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<Set> 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.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:32:20 -04:00
parent 4724f7fe92
commit 9d391434e0
4 changed files with 213 additions and 0 deletions

View File

@ -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 {
<aside className={styles.right}>
<CVEVelocityPanel />
<KEVPanel />
<RansomwarePanel />
<DShieldPanel />
</aside>
</main>

View File

@ -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;
}
}

View File

@ -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<Set<string>>(new Set())
const [flashKeys, setFlashKeys] = useState<Set<string>>(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 (
<Panel
title="RANSOMWARE"
subtitle="VICTIMS"
rawHref="https://ransomware.live/"
rawLabel="ransomware.live"
>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.victim}>Victim</th>
<th className={styles.group}>Group</th>
<th className={styles.cc}>CC</th>
<th className={styles.ago}>Ago</th>
</tr>
</thead>
<tbody>
{recent.map((v) => {
const key = victimKey(v)
const isFlashing = flashKeys.has(key)
return (
<tr key={key} className={isFlashing ? styles.flash : undefined}>
<td className={styles.victim} title={v.post_title}>
{v.post_title}
</td>
<td className={styles.group}>{v.group_name}</td>
<td className={styles.cc}>{v.country ?? '—'}</td>
<td className={styles.ago}>{fmtAgo(v.discovered, now)}</td>
</tr>
)
})}
</tbody>
</table>
</Panel>
)
}
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`
}

View File

@ -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<RansomwareStore>((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: [] }),
}))