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:
@@ -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')}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
13
decnet_web/src/test/fixtures/attacker.ts
vendored
13
decnet_web/src/test/fixtures/attacker.ts
vendored
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user