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:
parent
4724f7fe92
commit
9d391434e0
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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: [] }),
|
||||
}))
|
||||
Loading…
Reference in New Issue