diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx
index 1008e6bc..2dd56433 100644
--- a/decnet_web/src/components/AttackerDetail.tsx
+++ b/decnet_web/src/components/AttackerDetail.tsx
@@ -9,6 +9,7 @@ import EmptyState from './EmptyState/EmptyState';
import TTPsObservedSection from './TTPsObservedSection';
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
+import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
import { Tag } from './AttackerDetail/ui';
import type {
AttackerData,
@@ -1458,63 +1459,7 @@ const AttackerDetail: React.FC = () => {
- {/* Stats Row */}
-
-
-
{attacker.event_count}
-
EVENTS
-
-
-
{attacker.bounty_count}
-
BOUNTIES
-
-
-
{attacker.credential_count}
-
CREDENTIALS
-
-
-
{attacker.service_count}
-
SERVICES
-
-
-
{attacker.decky_count}
-
DECKIES
-
-
-
- {/* Scanned vs. Interacted — activity-depth signal */}
- {attacker.service_activity &&
- (attacker.service_activity.scanned.length > 0 ||
- attacker.service_activity.interacted.length > 0) && (
-
-
0
- ? `Services: ${attacker.service_activity.scanned.join(', ')}`
- : 'No services were scanned without engagement.'
- }
- >
-
- {attacker.service_activity.scanned.length}
-
-
SCANNED · SERVICES
-
-
0
- ? `Services: ${attacker.service_activity.interacted.join(', ')}`
- : 'No services were interacted with — scan-only attacker.'
- }
- >
-
- {attacker.service_activity.interacted.length}
-
-
INTERACTED WITH · SERVICES
-
-
- )}
+
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerStats.test.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerStats.test.tsx
new file mode 100644
index 00000000..3dd1ef11
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/AttackerStats.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { makeAttacker } from '../../../test/fixtures';
+import { AttackerStats } from './AttackerStats';
+
+describe('AttackerStats', () => {
+ it('renders the five top-line counter cards from attacker fields', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('99')).toBeInTheDocument();
+ expect(screen.getByText('7')).toBeInTheDocument();
+ expect(screen.getByText('5')).toBeInTheDocument();
+ expect(screen.getByText('EVENTS')).toBeInTheDocument();
+ expect(screen.getByText('BOUNTIES')).toBeInTheDocument();
+ expect(screen.getByText('CREDENTIALS')).toBeInTheDocument();
+ expect(screen.getByText('SERVICES')).toBeInTheDocument();
+ expect(screen.getByText('DECKIES')).toBeInTheDocument();
+ });
+
+ it('renders the scan-vs-interact row when activity has any signal', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('SCANNED · SERVICES')).toBeInTheDocument();
+ expect(screen.getByText('INTERACTED WITH · SERVICES')).toBeInTheDocument();
+ });
+
+ it('hides the scan-vs-interact row when both arrays are empty', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('SCANNED · SERVICES')).not.toBeInTheDocument();
+ });
+
+ it('hides the scan-vs-interact row when service_activity is undefined', () => {
+ const attacker = makeAttacker();
+ delete (attacker as Partial).service_activity;
+ render();
+ expect(screen.queryByText('SCANNED · SERVICES')).not.toBeInTheDocument();
+ });
+});
diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerStats.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerStats.tsx
new file mode 100644
index 00000000..d88c724c
--- /dev/null
+++ b/decnet_web/src/components/AttackerDetail/sections/AttackerStats.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import type { AttackerData } from '../types';
+
+interface Props {
+ attacker: AttackerData;
+}
+
+/** Top-line counters: events / bounties / credentials / services / deckies,
+ * plus a 2-up scan-vs-interact card when the activity rollup has any
+ * signal. The activity row stays hidden when both arrays are empty so
+ * scan-only attackers without enrichment data don't render dead cards. */
+export const AttackerStats: React.FC = ({ attacker }) => {
+ const activity = attacker.service_activity;
+ const showActivity =
+ !!activity && (activity.scanned.length > 0 || activity.interacted.length > 0);
+
+ return (
+ <>
+
+
+
{attacker.event_count}
+
EVENTS
+
+
+
{attacker.bounty_count}
+
BOUNTIES
+
+
+
{attacker.credential_count}
+
CREDENTIALS
+
+
+
{attacker.service_count}
+
SERVICES
+
+
+
{attacker.decky_count}
+
DECKIES
+
+
+
+ {showActivity && activity && (
+
+
0
+ ? `Services: ${activity.scanned.join(', ')}`
+ : 'No services were scanned without engagement.'
+ }
+ >
+
{activity.scanned.length}
+
SCANNED · SERVICES
+
+
0
+ ? `Services: ${activity.interacted.join(', ')}`
+ : 'No services were interacted with — scan-only attacker.'
+ }
+ >
+
{activity.interacted.length}
+
INTERACTED WITH · SERVICES
+
+
+ )}
+ >
+ );
+};