refactor(decnet_web/AttackerDetail): extract ServicesTargeted section

Lift the SERVICES TARGETED collapsible — interactive two-tone badge
chips with click-to-filter — into its own section. The selection
state was already lifted into useAttackerDetail in the prior
commits, so the section just consumes serviceFilter /
setServiceFilter as props.

- New AttackerDetail/sections/ServicesTargeted.tsx
- ServicesTargeted.test.tsx covers badge rendering, empty state,
  inactive-click-sets-filter, and active-click-clears-filter
- AttackerFixture grows ip_leaks/ip_leaks_total fields so the
  TimelineSection rotation test (added in the prior commit) keeps
  passing under the new factory shape
This commit is contained in:
2026-05-09 04:44:25 -04:00
parent 95e1a4ab7a
commit 7b21f31078
4 changed files with 183 additions and 46 deletions

View File

@@ -11,6 +11,7 @@ import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
import { Tag, Section } from './AttackerDetail/ui';
import type {
AttackerBehavior,
@@ -1332,52 +1333,13 @@ const AttackerDetail: React.FC = () => {
onToggle={() => toggle('timeline')}
/>
{/* Services */}
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
<div style={{ padding: '16px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{attacker.services.length > 0 ? attacker.services.map((svc) => {
const isActive = serviceFilter === svc;
const interacted = attacker.service_activity?.interacted.includes(svc) ?? false;
const baseStyle: React.CSSProperties = interacted
? { borderColor: 'var(--accent-color)', color: 'var(--accent-color)', background: 'var(--violet-tint-10)' }
: { opacity: 0.55 };
const activeStyle: React.CSSProperties = isActive
? interacted
? { backgroundColor: 'var(--accent-color)', color: 'var(--bg-color)', borderColor: 'var(--accent-color)', opacity: 1 }
: { backgroundColor: 'var(--text-color)', color: 'var(--bg-color)', borderColor: 'var(--text-color)', opacity: 1 }
: {};
return (
<span
key={svc}
className="service-badge"
style={{
fontSize: '0.85rem', padding: '4px 12px', cursor: 'pointer',
...baseStyle,
...activeStyle,
}}
onClick={() => setServiceFilter(isActive ? null : svc)}
title={
isActive
? 'Clear filter'
: `Filter by ${svc.toUpperCase()}${interacted ? 'interacted with' : 'scanned only'}`
}
>
{interacted ? '· ' : ''}{svc.toUpperCase()}
</span>
);
}) : (
<span className="dim">No services recorded</span>
)}
</div>
{attacker.services.length > 0 && (
<div style={{ marginTop: '12px', fontSize: '0.7rem', display: 'flex', gap: '16px' }}>
<span style={{ color: 'var(--accent-color)' }}>· INTERACTED</span>
<span className="dim">SCAN-ONLY</span>
</div>
)}
</div>
</Section>
<ServicesTargeted
attacker={attacker}
serviceFilter={serviceFilter}
setServiceFilter={setServiceFilter}
open={openSections.services}
onToggle={() => toggle('services')}
/>
{/* Deckies & Traversal */}
<Section title="DECKY INTERACTIONS" open={openSections.deckies} onToggle={() => toggle('deckies')}>

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { makeAttacker } from '../../../test/fixtures';
import { ServicesTargeted } from './ServicesTargeted';
describe('ServicesTargeted', () => {
it('renders one upper-cased badge per service', () => {
render(
<ServicesTargeted
attacker={makeAttacker({ services: ['ssh', 'http', 'smtp'] })}
serviceFilter={null}
setServiceFilter={() => {}}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText(/SSH/)).toBeInTheDocument();
expect(screen.getByText(/HTTP/)).toBeInTheDocument();
expect(screen.getByText(/SMTP/)).toBeInTheDocument();
});
it('shows the empty-state when services is []', () => {
render(
<ServicesTargeted
attacker={makeAttacker({ services: [] })}
serviceFilter={null}
setServiceFilter={() => {}}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText('No services recorded')).toBeInTheDocument();
});
it('selecting an inactive badge invokes setServiceFilter with the slug', async () => {
const setServiceFilter = vi.fn();
const user = userEvent.setup();
render(
<ServicesTargeted
attacker={makeAttacker({ services: ['ssh'] })}
serviceFilter={null}
setServiceFilter={setServiceFilter}
open={true}
onToggle={() => {}}
/>,
);
await user.click(screen.getByText(/SSH/));
expect(setServiceFilter).toHaveBeenCalledWith('ssh');
});
it('clicking the active badge clears the filter (passes null)', async () => {
const setServiceFilter = vi.fn();
const user = userEvent.setup();
render(
<ServicesTargeted
attacker={makeAttacker({ services: ['ssh'] })}
serviceFilter={'ssh'}
setServiceFilter={setServiceFilter}
open={true}
onToggle={() => {}}
/>,
);
await user.click(screen.getByText(/SSH/));
expect(setServiceFilter).toHaveBeenCalledWith(null);
});
});

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Section } from '../ui';
import type { AttackerData } from '../types';
interface Props {
attacker: AttackerData;
serviceFilter: string | null;
setServiceFilter: (s: string | null) => void;
open: boolean;
onToggle: () => void;
}
/** SERVICES TARGETED collapsible — interactive service-tag chips
* with two-tone styling (interacted vs. scan-only) plus a click
* filter. Selection state lives in the page-level data hook so
* CommandsViewer can subscribe to the same filter. */
export const ServicesTargeted: React.FC<Props> = ({
attacker,
serviceFilter,
setServiceFilter,
open,
onToggle,
}) => (
<Section title="SERVICES TARGETED" open={open} onToggle={onToggle}>
<div style={{ padding: '16px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{attacker.services.length > 0 ? (
attacker.services.map((svc) => {
const isActive = serviceFilter === svc;
const interacted =
attacker.service_activity?.interacted.includes(svc) ?? false;
const baseStyle: React.CSSProperties = interacted
? {
borderColor: 'var(--accent-color)',
color: 'var(--accent-color)',
background: 'var(--violet-tint-10)',
}
: { opacity: 0.55 };
const activeStyle: React.CSSProperties = isActive
? interacted
? {
backgroundColor: 'var(--accent-color)',
color: 'var(--bg-color)',
borderColor: 'var(--accent-color)',
opacity: 1,
}
: {
backgroundColor: 'var(--text-color)',
color: 'var(--bg-color)',
borderColor: 'var(--text-color)',
opacity: 1,
}
: {};
return (
<span
key={svc}
className="service-badge"
style={{
fontSize: '0.85rem',
padding: '4px 12px',
cursor: 'pointer',
...baseStyle,
...activeStyle,
}}
onClick={() => setServiceFilter(isActive ? null : svc)}
title={
isActive
? 'Clear filter'
: `Filter by ${svc.toUpperCase()}${interacted ? 'interacted with' : 'scanned only'}`
}
>
{interacted ? '· ' : ''}{svc.toUpperCase()}
</span>
);
})
) : (
<span className="dim">No services recorded</span>
)}
</div>
{attacker.services.length > 0 && (
<div
style={{
marginTop: '12px',
fontSize: '0.7rem',
display: 'flex',
gap: '16px',
}}
>
<span style={{ color: 'var(--accent-color)' }}>· INTERACTED</span>
<span className="dim">SCAN-ONLY</span>
</div>
)}
</div>
</Section>
);

View File

@@ -25,6 +25,19 @@ export interface AttackerFixture {
behavior: null;
service_activity: { interacted: string[]; scanned: string[] };
observations: never[];
ip_leaks?: Array<{
timestamp: string;
decky?: string;
service?: string;
bounty_type: string;
payload: {
source_ip?: string;
real_ip_claim?: string;
source_header?: string;
headers_seen?: Record<string, string>;
};
}>;
ip_leaks_total?: number;
}
export const makeAttacker = (overrides: Partial<AttackerFixture> = {}): AttackerFixture => ({