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 { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
||||||
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
|
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
|
||||||
import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
|
import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
|
||||||
|
import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
|
||||||
import { Tag, Section } from './AttackerDetail/ui';
|
import { Tag, Section } from './AttackerDetail/ui';
|
||||||
import type {
|
import type {
|
||||||
AttackerBehavior,
|
AttackerBehavior,
|
||||||
@@ -1332,52 +1333,13 @@ const AttackerDetail: React.FC = () => {
|
|||||||
onToggle={() => toggle('timeline')}
|
onToggle={() => toggle('timeline')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Services */}
|
<ServicesTargeted
|
||||||
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
attacker={attacker}
|
||||||
<div style={{ padding: '16px' }}>
|
serviceFilter={serviceFilter}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
setServiceFilter={setServiceFilter}
|
||||||
{attacker.services.length > 0 ? attacker.services.map((svc) => {
|
open={openSections.services}
|
||||||
const isActive = serviceFilter === svc;
|
onToggle={() => toggle('services')}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Deckies & Traversal */}
|
{/* Deckies & Traversal */}
|
||||||
<Section title="DECKY INTERACTIONS" open={openSections.deckies} onToggle={() => toggle('deckies')}>
|
<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;
|
behavior: null;
|
||||||
service_activity: { interacted: string[]; scanned: string[] };
|
service_activity: { interacted: string[]; scanned: string[] };
|
||||||
observations: never[];
|
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 => ({
|
export const makeAttacker = (overrides: Partial<AttackerFixture> = {}): AttackerFixture => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user