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:
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user