From 7b21f310785f03d12dadc5d9e2fbd515256b738f Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 04:44:25 -0400 Subject: [PATCH] refactor(decnet_web/AttackerDetail): extract ServicesTargeted section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- decnet_web/src/components/AttackerDetail.tsx | 54 ++--------- .../sections/ServicesTargeted.test.tsx | 67 +++++++++++++ .../sections/ServicesTargeted.tsx | 95 +++++++++++++++++++ decnet_web/src/test/fixtures/attacker.ts | 13 +++ 4 files changed, 183 insertions(+), 46 deletions(-) create mode 100644 decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.test.tsx create mode 100644 decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.tsx diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 964e9a67..b5f0705a 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -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 */} -
toggle('services')}> -
-
- {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 ( - setServiceFilter(isActive ? null : svc)} - title={ - isActive - ? 'Clear filter' - : `Filter by ${svc.toUpperCase()} — ${interacted ? 'interacted with' : 'scanned only'}` - } - > - {interacted ? '· ' : ''}{svc.toUpperCase()} - - ); - }) : ( - No services recorded - )} -
- {attacker.services.length > 0 && ( -
- · INTERACTED - SCAN-ONLY -
- )} -
-
+ toggle('services')} + /> {/* Deckies & Traversal */}
toggle('deckies')}> diff --git a/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.test.tsx b/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.test.tsx new file mode 100644 index 00000000..e39988a3 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + />, + ); + 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( + {}} + />, + ); + await user.click(screen.getByText(/SSH/)); + expect(setServiceFilter).toHaveBeenCalledWith(null); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.tsx b/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.tsx new file mode 100644 index 00000000..aa45ead0 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/ServicesTargeted.tsx @@ -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 = ({ + attacker, + serviceFilter, + setServiceFilter, + open, + onToggle, +}) => ( +
+
+
+ {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 ( + setServiceFilter(isActive ? null : svc)} + title={ + isActive + ? 'Clear filter' + : `Filter by ${svc.toUpperCase()} — ${interacted ? 'interacted with' : 'scanned only'}` + } + > + {interacted ? '· ' : ''}{svc.toUpperCase()} + + ); + }) + ) : ( + No services recorded + )} +
+ {attacker.services.length > 0 && ( +
+ · INTERACTED + SCAN-ONLY +
+ )} +
+
+); diff --git a/decnet_web/src/test/fixtures/attacker.ts b/decnet_web/src/test/fixtures/attacker.ts index 98e34cd4..7f80e01d 100644 --- a/decnet_web/src/test/fixtures/attacker.ts +++ b/decnet_web/src/test/fixtures/attacker.ts @@ -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; + }; + }>; + ip_leaks_total?: number; } export const makeAttacker = (overrides: Partial = {}): AttackerFixture => ({