diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx
new file mode 100644
index 00000000..2a74819b
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx
@@ -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(
);
+ expect(screen.getByText('198.51.100.10')).toBeInTheDocument();
+ expect(screen.getByText('BR')).toBeInTheDocument();
+ });
+
+ it('omits the country tag when country_code is null', () => {
+ renderWithRouter(
+
,
+ );
+ expect(screen.queryByText('BR')).not.toBeInTheDocument();
+ expect(screen.queryByText('US')).not.toBeInTheDocument();
+ });
+
+ it('shows the TRAVERSAL badge when is_traversal is true', () => {
+ renderWithRouter(
+
,
+ );
+ 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(
+
,
+ );
+ 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(
+
,
+ );
+ expect(screen.queryByText(/IDENTITY/)).not.toBeInTheDocument();
+ });
+});
diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx
new file mode 100644
index 00000000..feb67769
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx
@@ -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
= ({ attacker }) => {
+ const navigate = useNavigate();
+ return (
+
+
+
+ {attacker.ip}
+
+ {attacker.country_code && (
+
+
+ {attacker.country_code}
+
+
+ )}
+ {attacker.is_traversal && (
+ TRAVERSAL
+ )}
+ {attacker.identity_id && (
+ navigate(`/identities/${attacker.identity_id}`)}
+ >
+ IDENTITY · {attacker.identity_id.slice(0, 8)}
+
+ )}
+
+ );
+};
diff --git a/decnet_web/src/components/AttackerDetail/ui.tsx b/decnet_web/src/components/AttackerDetail/ui.tsx
new file mode 100644
index 00000000..f6eb8eaf
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/ui.tsx
@@ -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,
+}) => (
+
+ {children}
+
+);
diff --git a/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts
index 43a710f6..859c7e2b 100644
--- a/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts
+++ b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts
@@ -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, { 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 }),
),