refactor(decnet_web/AttackerDetail): extract CommandsViewer section

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
This commit is contained in:
2026-05-09 04:45:41 -04:00
parent 7b21f31078
commit 9cee4b2e71
3 changed files with 228 additions and 68 deletions

View File

@@ -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 = () => {
<BehaviouralPrimitivesPanel observations={observations} attribution={attribution} />
</Section>
{/* Commands */}
{(() => {
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
return (
<Section
title={<>COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})</>}
open={openSections.commands}
onToggle={() => toggle('commands')}
right={openSections.commands && cmdTotalPages > 1 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {cmdPage} of {cmdTotalPages}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={cmdPage <= 1}
onClick={() => setCmdPage(cmdPage - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: cmdPage <= 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={cmdPage >= cmdTotalPages}
onClick={() => setCmdPage(cmdPage + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: cmdPage >= cmdTotalPages ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div>
</div>
) : undefined}
>
{commands.length > 0 ? (
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>SERVICE</th>
<th>DECKY</th>
<th>COMMAND</th>
</tr>
</thead>
<tbody>
{commands.map((cmd, i) => (
<tr key={i}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'}
</td>
<td>{cmd.service}</td>
<td className="violet-accent">{cmd.decky}</td>
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>{cmd.command}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
icon={Terminal}
title={serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'}
size="compact"
/>
)}
</Section>
);
})()}
<CommandsViewer
commands={commands}
cmdTotal={cmdTotal}
cmdPage={cmdPage}
cmdLimit={cmdLimit}
setCmdPage={setCmdPage}
serviceFilter={serviceFilter}
open={openSections.commands}
onToggle={() => toggle('commands')}
/>
{/* Fingerprints — grouped by type */}
{(() => {

View File

@@ -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> = {}): 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(
<CommandsViewer
commands={[row()]}
cmdTotal={5}
cmdPage={1}
cmdLimit={50}
setCmdPage={() => {}}
serviceFilter={null}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText(/COMMANDS \(5\)/)).toBeInTheDocument();
});
it('appends the filter to the title when serviceFilter is set', () => {
render(
<CommandsViewer
commands={[row()]}
cmdTotal={3}
cmdPage={1}
cmdLimit={50}
setCmdPage={() => {}}
serviceFilter="ssh"
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText(/COMMANDS \(3 SSH\)/)).toBeInTheDocument();
});
it('shows the empty state when commands is []', () => {
render(
<CommandsViewer
commands={[]}
cmdTotal={0}
cmdPage={1}
cmdLimit={50}
setCmdPage={() => {}}
serviceFilter={null}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText('NO COMMANDS CAPTURED')).toBeInTheDocument();
});
it('hides pagination when total fits on one page', () => {
render(
<CommandsViewer
commands={[row()]}
cmdTotal={1}
cmdPage={1}
cmdLimit={50}
setCmdPage={() => {}}
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(
<CommandsViewer
commands={[row()]}
cmdTotal={250}
cmdPage={3}
cmdLimit={50}
setCmdPage={setCmdPage}
serviceFilter={null}
open={true}
onToggle={() => {}}
/>,
);
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);
});
});

View File

@@ -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<Props> = ({
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 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {cmdPage} of {cmdTotalPages}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={cmdPage <= 1}
onClick={() => setCmdPage(cmdPage - 1)}
style={{
padding: '4px',
border: '1px solid var(--border-color)',
opacity: cmdPage <= 1 ? 0.3 : 1,
}}
>
<ChevronLeft size={16} />
</button>
<button
disabled={cmdPage >= cmdTotalPages}
onClick={() => setCmdPage(cmdPage + 1)}
style={{
padding: '4px',
border: '1px solid var(--border-color)',
opacity: cmdPage >= cmdTotalPages ? 0.3 : 1,
}}
>
<ChevronRight size={16} />
</button>
</div>
</div>
) : undefined;
return (
<Section title={title} open={open} onToggle={onToggle} right={right}>
{commands.length > 0 ? (
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>SERVICE</th>
<th>DECKY</th>
<th>COMMAND</th>
</tr>
</thead>
<tbody>
{commands.map((cmd, i) => (
<tr key={i}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'}
</td>
<td>{cmd.service}</td>
<td className="violet-accent">{cmd.decky}</td>
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
{cmd.command}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
icon={Terminal}
title={
serviceFilter
? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED`
: 'NO COMMANDS CAPTURED'
}
size="compact"
/>
)}
</Section>
);
};