refactor(decnet_web/AttackerDetail): extract TimelineSection
Lift the TIMELINE collapsible (timestamps, ASN, reverse DNS, leaked-IPs row with rotation detection) into its own section. LeakedIPsRow + the rotation/inline-limit constants come along since they were only ever used here. Also moves the shared `Section` collapsible primitive into AttackerDetail/ui.tsx so the remaining sections can adopt the template without re-importing through the parent module. - New AttackerDetail/sections/TimelineSection.tsx (LeakedIPsRow inline as a private helper) - AttackerDetail/ui.tsx now exports both Tag and Section - AttackerDetail.tsx loses LeakedIPsRow, the Section helper, the Timeline JSX block, and now-unused imports (ChevronUp, ChevronDown, AttackerData) - TimelineSection.test.tsx covers timestamps, unknown-origin path, rotation badge, empty leaks, collapse, and toggle callback
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 api from '../utils/api';
|
||||||
import ArtifactDrawer from './ArtifactDrawer';
|
import ArtifactDrawer from './ArtifactDrawer';
|
||||||
import MailDrawer from './MailDrawer';
|
import MailDrawer from './MailDrawer';
|
||||||
@@ -10,9 +10,9 @@ import TTPsObservedSection from './TTPsObservedSection';
|
|||||||
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
||||||
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
||||||
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
|
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 {
|
import type {
|
||||||
AttackerData,
|
|
||||||
AttackerBehavior,
|
AttackerBehavior,
|
||||||
BehaviouralObservation,
|
BehaviouralObservation,
|
||||||
AttributionPrimitiveState,
|
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 }) => (
|
|
||||||
<div className="logs-section">
|
|
||||||
<div
|
|
||||||
className="section-header"
|
|
||||||
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
|
|
||||||
<h2>{title}</h2>
|
|
||||||
</div>
|
|
||||||
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
|
|
||||||
</div>
|
|
||||||
{open && children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Leaked-IPs row (truncated view + rotation-detection badge) ────────────
|
|
||||||
|
|
||||||
const ROTATION_THRESHOLD = 20;
|
|
||||||
const INLINE_LIMIT = 1;
|
|
||||||
|
|
||||||
interface LeakedIPsRowProps {
|
|
||||||
leaks: NonNullable<AttackerData['ip_leaks']>;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ 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 (
|
|
||||||
<div>
|
|
||||||
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
|
|
||||||
LEAKED IPs:{' '}
|
|
||||||
</span>
|
|
||||||
{rotationDetected && (
|
|
||||||
<span
|
|
||||||
style={{ marginRight: 8, display: 'inline-block' }}
|
|
||||||
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
|
|
||||||
>
|
|
||||||
<Tag color="var(--alert, #ff4d4d)">
|
|
||||||
ROTATION · {total}
|
|
||||||
</Tag>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{visible.map((ip, i, arr) => (
|
|
||||||
<span
|
|
||||||
key={ip}
|
|
||||||
style={{
|
|
||||||
color: 'var(--warn, #e0a040)',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
title={ipTooltip(ip)}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
{i < arr.length - 1 ? ', ' : ''}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{!expanded && hiddenInList > 0 && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--warn, #e0a040)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ {hiddenInList} more
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{remainingBeyondSample > 0 && (
|
|
||||||
<span
|
|
||||||
className="dim"
|
|
||||||
style={{ marginLeft: 6, fontSize: '0.75rem' }}
|
|
||||||
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
|
|
||||||
>
|
|
||||||
(+{remainingBeyondSample} beyond sample)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--accent-color)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
collapse
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
|
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
|
// 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" */}
|
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
|
||||||
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
|
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
|
||||||
|
|
||||||
{/* Timestamps */}
|
<TimelineSection
|
||||||
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
|
attacker={attacker}
|
||||||
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '32px', fontSize: '0.85rem' }}>
|
open={openSections.timeline}
|
||||||
<div>
|
onToggle={() => toggle('timeline')}
|
||||||
<span className="dim">FIRST SEEN: </span>
|
/>
|
||||||
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="dim">LAST SEEN: </span>
|
|
||||||
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="dim">UPDATED: </span>
|
|
||||||
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="dim">ORIGIN: </span>
|
|
||||||
{attacker.country_code ? (
|
|
||||||
<span className="matrix-text">
|
|
||||||
{attacker.country_code}
|
|
||||||
{attacker.country_source && (
|
|
||||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
|
||||||
({attacker.country_source})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="dim">unknown</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="dim">AS: </span>
|
|
||||||
{attacker.asn != null ? (
|
|
||||||
<span className="matrix-text">
|
|
||||||
AS{attacker.asn}
|
|
||||||
{attacker.as_name && (
|
|
||||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
|
||||||
{attacker.as_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{attacker.asn_source && (
|
|
||||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
|
||||||
({attacker.asn_source})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="dim">unknown</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="dim">REVERSE DNS: </span>
|
|
||||||
{attacker.ptr_record ? (
|
|
||||||
<span
|
|
||||||
className="matrix-text"
|
|
||||||
style={{ fontFamily: 'monospace' }}
|
|
||||||
title="One-shot PTR record resolved at first sighting."
|
|
||||||
>
|
|
||||||
{attacker.ptr_record}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="dim">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
|
|
||||||
<LeakedIPsRow
|
|
||||||
leaks={attacker.ip_leaks}
|
|
||||||
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker({
|
||||||
|
asn: 12345,
|
||||||
|
as_name: 'EXAMPLE-AS',
|
||||||
|
asn_source: 'ipinfo',
|
||||||
|
ptr_record: 'host.example.com',
|
||||||
|
})}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker({ country_code: null, asn: null })}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText('unknown')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the ROTATION badge when ip_leaks total >= 20', () => {
|
||||||
|
render(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker({
|
||||||
|
ip_leaks: [
|
||||||
|
{
|
||||||
|
timestamp: '2026-05-01T10:00:00Z',
|
||||||
|
bounty_type: 'xff_leak',
|
||||||
|
payload: { real_ip_claim: '1.1.1.1', source_header: 'X-Forwarded-For' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ip_leaks_total: 42,
|
||||||
|
})}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/ROTATION · 42/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render leaked-IPs row when ip_leaks is empty', () => {
|
||||||
|
render(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker()}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/LEAKED IPs/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides body content when open=false', () => {
|
||||||
|
render(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker()}
|
||||||
|
open={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TimelineSection
|
||||||
|
attacker={makeAttacker()}
|
||||||
|
open={true}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText('TIMELINE'));
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AttackerData['ip_leaks']>;
|
||||||
|
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<LeakedIPsRowProps> = ({ 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 (
|
||||||
|
<div>
|
||||||
|
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
|
||||||
|
LEAKED IPs:{' '}
|
||||||
|
</span>
|
||||||
|
{rotationDetected && (
|
||||||
|
<span
|
||||||
|
style={{ marginRight: 8, display: 'inline-block' }}
|
||||||
|
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
|
||||||
|
>
|
||||||
|
<Tag color="var(--alert, #ff4d4d)">
|
||||||
|
ROTATION · {total}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visible.map((ip, i, arr) => (
|
||||||
|
<span
|
||||||
|
key={ip}
|
||||||
|
style={{ color: 'var(--warn, #e0a040)', fontFamily: 'monospace' }}
|
||||||
|
title={ipTooltip(ip)}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
{i < arr.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!expanded && hiddenInList > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--warn, #e0a040)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {hiddenInList} more
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{remainingBeyondSample > 0 && (
|
||||||
|
<span
|
||||||
|
className="dim"
|
||||||
|
style={{ marginLeft: 6, fontSize: '0.75rem' }}
|
||||||
|
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
|
||||||
|
>
|
||||||
|
(+{remainingBeyondSample} beyond sample)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--accent-color)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
collapse
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Props> = ({ attacker, open, onToggle }) => (
|
||||||
|
<Section title="TIMELINE" open={open} onToggle={onToggle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '32px',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="dim">FIRST SEEN: </span>
|
||||||
|
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">LAST SEEN: </span>
|
||||||
|
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">UPDATED: </span>
|
||||||
|
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">ORIGIN: </span>
|
||||||
|
{attacker.country_code ? (
|
||||||
|
<span className="matrix-text">
|
||||||
|
{attacker.country_code}
|
||||||
|
{attacker.country_source && (
|
||||||
|
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||||
|
({attacker.country_source})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="dim">unknown</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">AS: </span>
|
||||||
|
{attacker.asn != null ? (
|
||||||
|
<span className="matrix-text">
|
||||||
|
AS{attacker.asn}
|
||||||
|
{attacker.as_name && (
|
||||||
|
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||||
|
{attacker.as_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{attacker.asn_source && (
|
||||||
|
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||||
|
({attacker.asn_source})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="dim">unknown</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">REVERSE DNS: </span>
|
||||||
|
{attacker.ptr_record ? (
|
||||||
|
<span
|
||||||
|
className="matrix-text"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
title="One-shot PTR record resolved at first sighting."
|
||||||
|
>
|
||||||
|
{attacker.ptr_record}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="dim">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
|
||||||
|
<LeakedIPsRow
|
||||||
|
leaks={attacker.ip_leaks}
|
||||||
|
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronUp } from '../../icons';
|
||||||
|
|
||||||
/** Pill-style tag chip used throughout the AttackerDetail surface
|
/** Pill-style tag chip used throughout the AttackerDetail surface
|
||||||
* for badges, filters, and category labels. Color drives both the
|
* 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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 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 }) => (
|
||||||
|
<div className="logs-section">
|
||||||
|
<div
|
||||||
|
className="section-header"
|
||||||
|
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
|
||||||
|
</div>
|
||||||
|
{open && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user