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:
@@ -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)' }}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
22
decnet_web/src/components/AttackerDetail/ui.tsx
Normal file
22
decnet_web/src/components/AttackerDetail/ui.tsx
Normal 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>
|
||||
);
|
||||
@@ -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 }),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user