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 TTPsObservedSection from './TTPsObservedSection';
|
||||||
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
||||||
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
||||||
|
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
|
||||||
import { Tag } from './AttackerDetail/ui';
|
import { Tag } from './AttackerDetail/ui';
|
||||||
import type {
|
import type {
|
||||||
AttackerData,
|
AttackerData,
|
||||||
@@ -1458,63 +1459,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
|
|
||||||
<AttackerHeader attacker={attacker} />
|
<AttackerHeader attacker={attacker} />
|
||||||
|
|
||||||
{/* Stats Row */}
|
<AttackerStats attacker={attacker} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
|
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
|
||||||
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
|
<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