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
+
+
+ )} + + ); +};