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 React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 api from '../utils/api';
|
||||||
import ArtifactDrawer from './ArtifactDrawer';
|
import ArtifactDrawer from './ArtifactDrawer';
|
||||||
import MailDrawer from './MailDrawer';
|
import MailDrawer from './MailDrawer';
|
||||||
@@ -12,6 +12,7 @@ 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 { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
|
||||||
|
import { CommandsViewer } from './AttackerDetail/sections/CommandsViewer';
|
||||||
import { Tag, Section } from './AttackerDetail/ui';
|
import { Tag, Section } from './AttackerDetail/ui';
|
||||||
import type {
|
import type {
|
||||||
AttackerBehavior,
|
AttackerBehavior,
|
||||||
@@ -1398,73 +1399,16 @@ const AttackerDetail: React.FC = () => {
|
|||||||
<BehaviouralPrimitivesPanel observations={observations} attribution={attribution} />
|
<BehaviouralPrimitivesPanel observations={observations} attribution={attribution} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Commands */}
|
<CommandsViewer
|
||||||
{(() => {
|
commands={commands}
|
||||||
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
|
cmdTotal={cmdTotal}
|
||||||
return (
|
cmdPage={cmdPage}
|
||||||
<Section
|
cmdLimit={cmdLimit}
|
||||||
title={<>COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})</>}
|
setCmdPage={setCmdPage}
|
||||||
open={openSections.commands}
|
serviceFilter={serviceFilter}
|
||||||
onToggle={() => toggle('commands')}
|
open={openSections.commands}
|
||||||
right={openSections.commands && cmdTotalPages > 1 ? (
|
onToggle={() => toggle('commands')}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Fingerprints — grouped by type */}
|
{/* 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