refactor(decnet_web/AttackerDetail): extract AttackerHeader section

Lift the header (IP, country tag, traversal badge, identity badge)
into its own section component. Tag helper moves to a shared
AttackerDetail/ui.tsx so future sections can reuse it without
re-importing through AttackerDetail.tsx.

- New AttackerDetail/sections/AttackerHeader.tsx (~50 LOC)
- New AttackerDetail/ui.tsx for shared presentational helpers
- AttackerDetail.tsx imports both; local Tag definition deleted
- AttackerHeader.test.tsx covers country present/absent,
  TRAVERSAL badge, IDENTITY click-through, identity null path
This commit is contained in:
2026-05-09 04:39:30 -04:00
parent 22cfb10617
commit 653ae04e88
5 changed files with 130 additions and 54 deletions

View File

@@ -8,6 +8,8 @@ import SessionDrawer from './SessionDrawer';
import EmptyState from './EmptyState/EmptyState';
import TTPsObservedSection from './TTPsObservedSection';
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
import { Tag } from './AttackerDetail/ui';
import type {
AttackerData,
AttackerBehavior,
@@ -88,17 +90,6 @@ const seqClassColor = (cls: string): string | undefined => {
}
};
const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', letterSpacing: '1px',
border: `1px solid ${color || 'var(--text-color)'}`,
color: color || 'var(--text-color)',
background: `${color || 'var(--text-color)'}15`,
}}>
{children}
</span>
);
const FpTlsHashes: React.FC<{ p: any }> = ({ p }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<HashRow label="JA3" value={p.ja3} />
@@ -1465,44 +1456,7 @@ const AttackerDetail: React.FC = () => {
<span>BACK TO PROFILES</span>
</button>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Crosshair size={32} className="violet-accent" />
<h1 className="matrix-text" style={{ fontSize: '1.8rem', letterSpacing: '2px' }}>
{attacker.ip}
</h1>
{attacker.country_code && (
<Tag color="var(--text-color)">
<span
title={attacker.country_source ? `source: ${attacker.country_source}` : undefined}
style={{ letterSpacing: '2px' }}
>
{attacker.country_code}
</span>
</Tag>
)}
{attacker.is_traversal && (
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
)}
{/* Conditional Identity badge — surfaces only when the clusterer
has linked this observation to a resolved actor identity.
Zero behavior change when identity_id is null (which is
uniformly true until the clusterer ships). */}
{attacker.identity_id && (
<span
className="traversal-badge"
style={{
fontSize: '0.8rem',
cursor: 'pointer',
letterSpacing: '2px',
}}
title="Resolved identity — click to view all observations linked to this actor"
onClick={() => navigate(`/identities/${attacker.identity_id}`)}
>
IDENTITY · {attacker.identity_id.slice(0, 8)}
</span>
)}
</div>
<AttackerHeader attacker={attacker} />
{/* Stats Row */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithRouter } from '../../../test/renderWithRouter';
import { makeAttacker } from '../../../test/fixtures';
import { AttackerHeader } from './AttackerHeader';
describe('AttackerHeader', () => {
it('renders the IP and a country tag when country_code is present', () => {
renderWithRouter(<AttackerHeader attacker={makeAttacker({ country_code: 'BR' })} />);
expect(screen.getByText('198.51.100.10')).toBeInTheDocument();
expect(screen.getByText('BR')).toBeInTheDocument();
});
it('omits the country tag when country_code is null', () => {
renderWithRouter(
<AttackerHeader attacker={makeAttacker({ country_code: null })} />,
);
expect(screen.queryByText('BR')).not.toBeInTheDocument();
expect(screen.queryByText('US')).not.toBeInTheDocument();
});
it('shows the TRAVERSAL badge when is_traversal is true', () => {
renderWithRouter(
<AttackerHeader attacker={makeAttacker({ is_traversal: true })} />,
);
expect(screen.getByText('TRAVERSAL')).toBeInTheDocument();
});
it('renders the IDENTITY badge with first 8 chars and navigates on click', async () => {
const user = userEvent.setup();
const identity = 'aaaabbbb-cccc-dddd-eeee-ffffffffffff';
renderWithRouter(
<AttackerHeader attacker={makeAttacker({ identity_id: identity })} />,
);
const badge = screen.getByText(/IDENTITY · aaaabbbb/);
expect(badge).toBeInTheDocument();
// No assertion on navigation target; we just verify the click
// handler doesn't throw (router is present via renderWithRouter).
await user.click(badge);
});
it('omits the IDENTITY badge when identity_id is null', () => {
renderWithRouter(
<AttackerHeader attacker={makeAttacker({ identity_id: null })} />,
);
expect(screen.queryByText(/IDENTITY/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Crosshair } from '../../../icons';
import { Tag } from '../ui';
import type { AttackerData } from '../types';
interface Props {
attacker: AttackerData;
}
/** Page header: crosshair + IP + country / traversal / identity badges.
* The identity badge is click-through to the resolved-actor page. */
export const AttackerHeader: React.FC<Props> = ({ attacker }) => {
const navigate = useNavigate();
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Crosshair size={32} className="violet-accent" />
<h1 className="matrix-text" style={{ fontSize: '1.8rem', letterSpacing: '2px' }}>
{attacker.ip}
</h1>
{attacker.country_code && (
<Tag color="var(--text-color)">
<span
title={attacker.country_source ? `source: ${attacker.country_source}` : undefined}
style={{ letterSpacing: '2px' }}
>
{attacker.country_code}
</span>
</Tag>
)}
{attacker.is_traversal && (
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
)}
{attacker.identity_id && (
<span
className="traversal-badge"
style={{
fontSize: '0.8rem',
cursor: 'pointer',
letterSpacing: '2px',
}}
title="Resolved identity — click to view all observations linked to this actor"
onClick={() => navigate(`/identities/${attacker.identity_id}`)}
>
IDENTITY · {attacker.identity_id.slice(0, 8)}
</span>
)}
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
/** Pill-style tag chip used throughout the AttackerDetail surface
* for badges, filters, and category labels. Color drives both the
* border and a 15%-alpha fill (the suffix is hex alpha). */
export const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({
children,
color,
}) => (
<span
style={{
fontSize: '0.7rem',
padding: '2px 8px',
letterSpacing: '1px',
border: `1px solid ${color || 'var(--text-color)'}`,
color: color || 'var(--text-color)',
background: `${color || 'var(--text-color)'}15`,
}}
>
{children}
</span>
);

View File

@@ -25,9 +25,9 @@ import { useAttackerDetail } from './useAttackerDetail';
const ID = '11111111-1111-1111-1111-111111111111';
const attackerHandler = (body: unknown, status = 200) =>
const attackerHandler = (body: object, status = 200) =>
http.get(apiUrl(`/attackers/${ID}`), () =>
HttpResponse.json(body, { status }),
HttpResponse.json(body as Record<string, unknown>, { status }),
);
const stockHandlers = () => [
@@ -139,9 +139,10 @@ describe('useAttackerDetail', () => {
it('flags mailForbidden on 403', async () => {
server.use(
...stockHandlers().filter(
(h) => !h.info.path.endsWith('/mail'),
),
...stockHandlers().filter((h) => {
const p = h.info.path;
return typeof p === 'string' ? !p.endsWith('/mail') : true;
}),
http.get(apiUrl(`/attackers/${ID}/mail`), () =>
HttpResponse.json({ detail: 'forbidden' }, { status: 403 }),
),