diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx
index 2dd56433..964e9a67 100644
--- a/decnet_web/src/components/AttackerDetail.tsx
+++ b/decnet_web/src/components/AttackerDetail.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { Activity, AlertTriangle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
+import { Activity, AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
import api from '../utils/api';
import ArtifactDrawer from './ArtifactDrawer';
import MailDrawer from './MailDrawer';
@@ -10,9 +10,9 @@ import TTPsObservedSection from './TTPsObservedSection';
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
-import { Tag } from './AttackerDetail/ui';
+import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
+import { Tag, Section } from './AttackerDetail/ui';
import type {
- AttackerData,
AttackerBehavior,
BehaviouralObservation,
AttributionPrimitiveState,
@@ -1008,144 +1008,6 @@ export const BehaviouralPrimitivesPanel: React.FC<{
);
};
-// ─── Collapsible section ────────────────────────────────────────────────────
-
-const Section: React.FC<{
- title: React.ReactNode;
- right?: React.ReactNode;
- open: boolean;
- onToggle: () => void;
- children: React.ReactNode;
-}> = ({ title, right, open, onToggle, children }) => (
-
-
-
- {open ? : }
-
{title}
-
- {right &&
e.stopPropagation()}>{right}
}
-
- {open && children}
-
-);
-
-// ─── Leaked-IPs row (truncated view + rotation-detection badge) ────────────
-
-const ROTATION_THRESHOLD = 20;
-const INLINE_LIMIT = 1;
-
-interface LeakedIPsRowProps {
- leaks: NonNullable;
- total: number;
-}
-
-const LeakedIPsRow: React.FC = ({ leaks, total }) => {
- const [expanded, setExpanded] = useState(false);
- const distinctIPs = Array.from(
- new Set(
- leaks
- .map((l) => l.payload?.real_ip_claim)
- .filter((v): v is string => !!v),
- ),
- );
- const rotationDetected = total >= ROTATION_THRESHOLD;
- const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
- const hiddenInList = distinctIPs.length - visible.length;
- // Backend caps server-side leaks at 10 rows; "total" is the unbounded
- // count — may exceed what we actually have IP values for.
- const remainingBeyondSample = total - distinctIPs.length;
-
- const ipTooltip = (ip: string): string => {
- const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
- return latest
- ? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
- : '';
- };
-
- return (
-
-
- LEAKED IPs:{' '}
-
- {rotationDetected && (
-
-
- ROTATION · {total}
-
-
- )}
- {visible.map((ip, i, arr) => (
-
- {ip}
- {i < arr.length - 1 ? ', ' : ''}
-
- ))}
- {!expanded && hiddenInList > 0 && (
- <>
- {' '}
-
- >
- )}
- {remainingBeyondSample > 0 && (
-
- (+{remainingBeyondSample} beyond sample)
-
- )}
- {expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
- <>
- {' '}
-
- >
- )}
-
- );
-};
-
-
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
@@ -1464,78 +1326,11 @@ const AttackerDetail: React.FC = () => {
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
- {/* Timestamps */}
- toggle('timeline')}>
-
-
- FIRST SEEN:
- {new Date(attacker.first_seen).toLocaleString()}
-
-
- LAST SEEN:
- {new Date(attacker.last_seen).toLocaleString()}
-
-
- UPDATED:
- {new Date(attacker.updated_at).toLocaleString()}
-
-
- ORIGIN:
- {attacker.country_code ? (
-
- {attacker.country_code}
- {attacker.country_source && (
-
- ({attacker.country_source})
-
- )}
-
- ) : (
- unknown
- )}
-
-
- AS:
- {attacker.asn != null ? (
-
- AS{attacker.asn}
- {attacker.as_name && (
-
- {attacker.as_name}
-
- )}
- {attacker.asn_source && (
-
- ({attacker.asn_source})
-
- )}
-
- ) : (
- unknown
- )}
-
-
- REVERSE DNS:
- {attacker.ptr_record ? (
-
- {attacker.ptr_record}
-
- ) : (
- —
- )}
-
- {attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
-
- )}
-
-
+ toggle('timeline')}
+ />
{/* Services */}
toggle('services')}>
diff --git a/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx
new file mode 100644
index 00000000..edc9fb59
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { makeAttacker } from '../../../test/fixtures';
+import { TimelineSection } from './TimelineSection';
+
+describe('TimelineSection', () => {
+ it('renders the labelled timestamp + ASN + reverse-DNS rows', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('FIRST SEEN:')).toBeInTheDocument();
+ expect(screen.getByText('LAST SEEN:')).toBeInTheDocument();
+ expect(screen.getByText(/AS12345/)).toBeInTheDocument();
+ expect(screen.getByText('host.example.com')).toBeInTheDocument();
+ });
+
+ it('renders "unknown" when origin and ASN are absent', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getAllByText('unknown')).toHaveLength(2);
+ });
+
+ it('renders the ROTATION badge when ip_leaks total >= 20', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByText(/ROTATION · 42/)).toBeInTheDocument();
+ });
+
+ it('does NOT render leaked-IPs row when ip_leaks is empty', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.queryByText(/LEAKED IPs/)).not.toBeInTheDocument();
+ });
+
+ it('hides body content when open=false', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.queryByText('FIRST SEEN:')).not.toBeInTheDocument();
+ });
+
+ it('fires onToggle when the section header is clicked', async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByText('TIMELINE'));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx
new file mode 100644
index 00000000..f7517c53
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx
@@ -0,0 +1,204 @@
+import React, { useState } from 'react';
+import { Section, Tag } from '../ui';
+import type { AttackerData } from '../types';
+
+const ROTATION_THRESHOLD = 20;
+const INLINE_LIMIT = 1;
+
+interface LeakedIPsRowProps {
+ leaks: NonNullable;
+ total: number;
+}
+
+/** "LEAKED IPs" inline list with rotation-detection badge. The
+ * backend caps the server-side sample at 10 rows; `total` is the
+ * unbounded count, which may exceed what we have actual IP values
+ * for. Past ROTATION_THRESHOLD distinct claims, badge in red — that
+ * pattern is XFF-rotation / WAF-bypass probing, not real
+ * attribution leakage. */
+const LeakedIPsRow: React.FC = ({ leaks, total }) => {
+ const [expanded, setExpanded] = useState(false);
+ const distinctIPs = Array.from(
+ new Set(
+ leaks
+ .map((l) => l.payload?.real_ip_claim)
+ .filter((v): v is string => !!v),
+ ),
+ );
+ const rotationDetected = total >= ROTATION_THRESHOLD;
+ const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
+ const hiddenInList = distinctIPs.length - visible.length;
+ const remainingBeyondSample = total - distinctIPs.length;
+
+ const ipTooltip = (ip: string): string => {
+ const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
+ return latest
+ ? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
+ : '';
+ };
+
+ return (
+
+
+ LEAKED IPs:{' '}
+
+ {rotationDetected && (
+
+
+ ROTATION · {total}
+
+
+ )}
+ {visible.map((ip, i, arr) => (
+
+ {ip}
+ {i < arr.length - 1 ? ', ' : ''}
+
+ ))}
+ {!expanded && hiddenInList > 0 && (
+ <>
+ {' '}
+
+ >
+ )}
+ {remainingBeyondSample > 0 && (
+
+ (+{remainingBeyondSample} beyond sample)
+
+ )}
+ {expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
+ <>
+ {' '}
+
+ >
+ )}
+
+ );
+};
+
+interface Props {
+ attacker: AttackerData;
+ open: boolean;
+ onToggle: () => void;
+}
+
+/** TIMELINE collapsible — first/last seen, ASN/origin, reverse DNS,
+ * and leaked-IP row when the attacker has any ip_leaks. */
+export const TimelineSection: React.FC = ({ attacker, open, onToggle }) => (
+
+
+
+ FIRST SEEN:
+ {new Date(attacker.first_seen).toLocaleString()}
+
+
+ LAST SEEN:
+ {new Date(attacker.last_seen).toLocaleString()}
+
+
+ UPDATED:
+ {new Date(attacker.updated_at).toLocaleString()}
+
+
+ ORIGIN:
+ {attacker.country_code ? (
+
+ {attacker.country_code}
+ {attacker.country_source && (
+
+ ({attacker.country_source})
+
+ )}
+
+ ) : (
+ unknown
+ )}
+
+
+ AS:
+ {attacker.asn != null ? (
+
+ AS{attacker.asn}
+ {attacker.as_name && (
+
+ {attacker.as_name}
+
+ )}
+ {attacker.asn_source && (
+
+ ({attacker.asn_source})
+
+ )}
+
+ ) : (
+ unknown
+ )}
+
+
+ REVERSE DNS:
+ {attacker.ptr_record ? (
+
+ {attacker.ptr_record}
+
+ ) : (
+ —
+ )}
+
+ {attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
+
+ )}
+
+
+);
diff --git a/decnet_web/src/components/AttackerDetail/ui.tsx b/decnet_web/src/components/AttackerDetail/ui.tsx
index f6eb8eaf..20658445 100644
--- a/decnet_web/src/components/AttackerDetail/ui.tsx
+++ b/decnet_web/src/components/AttackerDetail/ui.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { ChevronDown, ChevronUp } from '../../icons';
/** Pill-style tag chip used throughout the AttackerDetail surface
* for badges, filters, and category labels. Color drives both the
@@ -20,3 +21,30 @@ export const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({
{children}
);
+
+/** Collapsible panel used by every section on the AttackerDetail page.
+ * The header is the toggle; an optional `right` slot hosts controls
+ * (filters, action buttons) whose clicks are stopped from bubbling
+ * to the toggle handler. */
+export const Section: React.FC<{
+ title: React.ReactNode;
+ right?: React.ReactNode;
+ open: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+}> = ({ title, right, open, onToggle, children }) => (
+
+
+
+ {open ? : }
+
{title}
+
+ {right &&
e.stopPropagation()}>{right}
}
+
+ {open && children}
+
+);