From 9cee4b2e7199679958395b1bb4ef48f762f9b2f8 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 04:45:41 -0400 Subject: [PATCH] refactor(decnet_web/AttackerDetail): extract CommandsViewer section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the COMMANDS collapsible — paginated table with header-bar prev/next controls — into its own section. The page math (cmdTotalPages = ceil(total/limit)) and conditional empty state both live in the section now. - New AttackerDetail/sections/CommandsViewer.tsx - CommandsViewer.test.tsx covers title formatting (unfiltered vs. filtered), empty state, single-page pagination hiding, and prev/next button behavior - AttackerDetail.tsx loses the IIFE-wrapped commands JSX block plus now-unused ChevronLeft/ChevronRight/Terminal imports --- decnet_web/src/components/AttackerDetail.tsx | 80 ++---------- .../sections/CommandsViewer.test.tsx | 102 ++++++++++++++++ .../sections/CommandsViewer.tsx | 114 ++++++++++++++++++ 3 files changed, 228 insertions(+), 68 deletions(-) create mode 100644 decnet_web/src/components/AttackerDetail/sections/CommandsViewer.test.tsx create mode 100644 decnet_web/src/components/AttackerDetail/sections/CommandsViewer.tsx diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index b5f0705a..328dabc9 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Activity, AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons'; +import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Package, FileText, Mail, AtSign } from '../icons'; import api from '../utils/api'; import ArtifactDrawer from './ArtifactDrawer'; import MailDrawer from './MailDrawer'; @@ -12,6 +12,7 @@ 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 { CommandsViewer } from './AttackerDetail/sections/CommandsViewer'; import { Tag, Section } from './AttackerDetail/ui'; import type { AttackerBehavior, @@ -1398,73 +1399,16 @@ const AttackerDetail: React.FC = () => { - {/* Commands */} - {(() => { - const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); - return ( -
COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})} - open={openSections.commands} - onToggle={() => toggle('commands')} - right={openSections.commands && cmdTotalPages > 1 ? ( -
- - Page {cmdPage} of {cmdTotalPages} - -
- - -
-
- ) : undefined} - > - {commands.length > 0 ? ( -
- - - - - - - - - - - {commands.map((cmd, i) => ( - - - - - - - ))} - -
TIMESTAMPSERVICEDECKYCOMMAND
- {cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'} - {cmd.service}{cmd.decky}{cmd.command}
-
- ) : ( - - )} -
- ); - })()} + toggle('commands')} + /> {/* Fingerprints — grouped by type */} {(() => { diff --git a/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.test.tsx b/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.test.tsx new file mode 100644 index 00000000..f85e7b37 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CommandsViewer } from './CommandsViewer'; +import type { CommandRow } from '../types'; + +const row = (overrides: Partial = {}): CommandRow => ({ + service: 'ssh', + decky: 'decoy-01', + command: 'whoami', + timestamp: '2026-05-09T11:00:00Z', + ...overrides, +}); + +describe('CommandsViewer', () => { + it('renders the title with the unfiltered total when serviceFilter is null', () => { + render( + {}} + serviceFilter={null} + open={true} + onToggle={() => {}} + />, + ); + expect(screen.getByText(/COMMANDS \(5\)/)).toBeInTheDocument(); + }); + + it('appends the filter to the title when serviceFilter is set', () => { + render( + {}} + serviceFilter="ssh" + open={true} + onToggle={() => {}} + />, + ); + expect(screen.getByText(/COMMANDS \(3 SSH\)/)).toBeInTheDocument(); + }); + + it('shows the empty state when commands is []', () => { + render( + {}} + serviceFilter={null} + open={true} + onToggle={() => {}} + />, + ); + expect(screen.getByText('NO COMMANDS CAPTURED')).toBeInTheDocument(); + }); + + it('hides pagination when total fits on one page', () => { + render( + {}} + serviceFilter={null} + open={true} + onToggle={() => {}} + />, + ); + expect(screen.queryByText(/Page 1 of/)).not.toBeInTheDocument(); + }); + + it('paginates: prev/next buttons fire setCmdPage with the right delta', async () => { + const user = userEvent.setup(); + const setCmdPage = vi.fn(); + render( + {}} + />, + ); + expect(screen.getByText('Page 3 of 5')).toBeInTheDocument(); + const buttons = screen.getAllByRole('button'); + await user.click(buttons[0]); // prev + expect(setCmdPage).toHaveBeenLastCalledWith(2); + await user.click(buttons[1]); // next + expect(setCmdPage).toHaveBeenLastCalledWith(4); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.tsx b/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.tsx new file mode 100644 index 00000000..17e892de --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/CommandsViewer.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { ChevronLeft, ChevronRight, Terminal } from '../../../icons'; +import EmptyState from '../../EmptyState/EmptyState'; +import { Section } from '../ui'; +import type { CommandRow } from '../types'; + +interface Props { + commands: CommandRow[]; + cmdTotal: number; + cmdPage: number; + cmdLimit: number; + setCmdPage: (n: number) => void; + serviceFilter: string | null; + open: boolean; + onToggle: () => void; +} + +/** COMMANDS collapsible — paginated table of captured shell commands. + * Pagination controls live in the Section's `right` slot so they + * share the header bar with the title; clicking them is filtered + * out of the toggle path by Section's stopPropagation. */ +export const CommandsViewer: React.FC = ({ + commands, + cmdTotal, + cmdPage, + cmdLimit, + setCmdPage, + serviceFilter, + open, + onToggle, +}) => { + const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); + const title = ( + <> + COMMANDS ({cmdTotal} + {serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''}) + + ); + const right = + open && cmdTotalPages > 1 ? ( +
+ + Page {cmdPage} of {cmdTotalPages} + +
+ + +
+
+ ) : undefined; + + return ( +
+ {commands.length > 0 ? ( +
+ + + + + + + + + + + {commands.map((cmd, i) => ( + + + + + + + ))} + +
TIMESTAMPSERVICEDECKYCOMMAND
+ {cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'} + {cmd.service}{cmd.decky} + {cmd.command} +
+
+ ) : ( + + )} +
+ ); +};