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 EmptyState from './EmptyState/EmptyState';
|
||||||
import TTPsObservedSection from './TTPsObservedSection';
|
import TTPsObservedSection from './TTPsObservedSection';
|
||||||
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
||||||
|
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
||||||
|
import { Tag } from './AttackerDetail/ui';
|
||||||
import type {
|
import type {
|
||||||
AttackerData,
|
AttackerData,
|
||||||
AttackerBehavior,
|
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 }) => (
|
const FpTlsHashes: React.FC<{ p: any }> = ({ p }) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
<HashRow label="JA3" value={p.ja3} />
|
<HashRow label="JA3" value={p.ja3} />
|
||||||
@@ -1465,44 +1456,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
<span>BACK TO PROFILES</span>
|
<span>BACK TO PROFILES</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
<AttackerHeader attacker={attacker} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
{/* Stats Row */}
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
<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 ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
const attackerHandler = (body: unknown, status = 200) =>
|
const attackerHandler = (body: object, status = 200) =>
|
||||||
http.get(apiUrl(`/attackers/${ID}`), () =>
|
http.get(apiUrl(`/attackers/${ID}`), () =>
|
||||||
HttpResponse.json(body, { status }),
|
HttpResponse.json(body as Record<string, unknown>, { status }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stockHandlers = () => [
|
const stockHandlers = () => [
|
||||||
@@ -139,9 +139,10 @@ describe('useAttackerDetail', () => {
|
|||||||
|
|
||||||
it('flags mailForbidden on 403', async () => {
|
it('flags mailForbidden on 403', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
...stockHandlers().filter(
|
...stockHandlers().filter((h) => {
|
||||||
(h) => !h.info.path.endsWith('/mail'),
|
const p = h.info.path;
|
||||||
),
|
return typeof p === 'string' ? !p.endsWith('/mail') : true;
|
||||||
|
}),
|
||||||
http.get(apiUrl(`/attackers/${ID}/mail`), () =>
|
http.get(apiUrl(`/attackers/${ID}/mail`), () =>
|
||||||
HttpResponse.json({ detail: 'forbidden' }, { status: 403 }),
|
HttpResponse.json({ detail: 'forbidden' }, { status: 403 }),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user