refactor(decnet_web/AttackerDetail): extract AttackerStats section

Lift the 5-up counter grid + the conditional scan-vs-interact row
into AttackerStats. The activity row's visibility predicate
collapses into a single boolean inside the section so the parent
no longer encodes UX rules.

- New AttackerDetail/sections/AttackerStats.tsx
- AttackerStats.test.tsx covers all-five counters, activity present,
  activity empty, and service_activity undefined paths.
This commit is contained in:
2026-05-09 04:40:34 -04:00
parent 653ae04e88
commit f524d283b7
3 changed files with 130 additions and 57 deletions

View File

@@ -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 = () => {
<AttackerHeader attacker={attacker} />
{/* Stats Row */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.event_count}</div>
<div className="stat-label">EVENTS</div>
</div>
<div className="stat-card">
<div className="stat-value violet-accent">{attacker.bounty_count}</div>
<div className="stat-label">BOUNTIES</div>
</div>
<div className="stat-card">
<div className="stat-value violet-accent">{attacker.credential_count}</div>
<div className="stat-label">CREDENTIALS</div>
</div>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.service_count}</div>
<div className="stat-label">SERVICES</div>
</div>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.decky_count}</div>
<div className="stat-label">DECKIES</div>
</div>
</div>
{/* Scanned vs. Interacted — activity-depth signal */}
{attacker.service_activity &&
(attacker.service_activity.scanned.length > 0 ||
attacker.service_activity.interacted.length > 0) && (
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
<div
className="stat-card"
title={
attacker.service_activity.scanned.length > 0
? `Services: ${attacker.service_activity.scanned.join(', ')}`
: 'No services were scanned without engagement.'
}
>
<div className="stat-value matrix-text">
{attacker.service_activity.scanned.length}
</div>
<div className="stat-label">SCANNED · SERVICES</div>
</div>
<div
className="stat-card"
title={
attacker.service_activity.interacted.length > 0
? `Services: ${attacker.service_activity.interacted.join(', ')}`
: 'No services were interacted with — scan-only attacker.'
}
>
<div className="stat-value violet-accent">
{attacker.service_activity.interacted.length}
</div>
<div className="stat-label">INTERACTED WITH · SERVICES</div>
</div>
</div>
)}
<AttackerStats attacker={attacker} />
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />

View File

@@ -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(
<AttackerStats
attacker={makeAttacker({
event_count: 99,
bounty_count: 7,
credential_count: 5,
service_count: 3,
decky_count: 2,
})}
/>,
);
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(
<AttackerStats
attacker={makeAttacker({
service_activity: { scanned: ['ssh', 'http'], interacted: ['ssh'] },
})}
/>,
);
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(
<AttackerStats
attacker={makeAttacker({
service_activity: { scanned: [], interacted: [] },
})}
/>,
);
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<typeof attacker>).service_activity;
render(<AttackerStats attacker={attacker} />);
expect(screen.queryByText('SCANNED · SERVICES')).not.toBeInTheDocument();
});
});

View File

@@ -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<Props> = ({ attacker }) => {
const activity = attacker.service_activity;
const showActivity =
!!activity && (activity.scanned.length > 0 || activity.interacted.length > 0);
return (
<>
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.event_count}</div>
<div className="stat-label">EVENTS</div>
</div>
<div className="stat-card">
<div className="stat-value violet-accent">{attacker.bounty_count}</div>
<div className="stat-label">BOUNTIES</div>
</div>
<div className="stat-card">
<div className="stat-value violet-accent">{attacker.credential_count}</div>
<div className="stat-label">CREDENTIALS</div>
</div>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.service_count}</div>
<div className="stat-label">SERVICES</div>
</div>
<div className="stat-card">
<div className="stat-value matrix-text">{attacker.decky_count}</div>
<div className="stat-label">DECKIES</div>
</div>
</div>
{showActivity && activity && (
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
<div
className="stat-card"
title={
activity.scanned.length > 0
? `Services: ${activity.scanned.join(', ')}`
: 'No services were scanned without engagement.'
}
>
<div className="stat-value matrix-text">{activity.scanned.length}</div>
<div className="stat-label">SCANNED · SERVICES</div>
</div>
<div
className="stat-card"
title={
activity.interacted.length > 0
? `Services: ${activity.interacted.join(', ')}`
: 'No services were interacted with — scan-only attacker.'
}
>
<div className="stat-value violet-accent">{activity.interacted.length}</div>
<div className="stat-label">INTERACTED WITH · SERVICES</div>
</div>
</div>
)}
</>
);
};