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:
@@ -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 */}
|
||||
{(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user