feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- Add getC2Tasks / listCallbackHistory / importC2 API functions + types - useC2Tasks with 2500ms polling (stops when all tasks completed) - useC2CallbackHistory, useImportC2 hooks - C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator) - C2CallbackPicker extracted as shared component (reused in both modals) - ImportC2HistoryModal: 2-step callback picker → paginated history table - SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button - 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
126
frontend/src/components/C2TasksPanel.tsx
Normal file
126
frontend/src/components/C2TasksPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { useC2Tasks } from '@/hooks/useC2';
|
||||
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
||||
|
||||
interface C2TasksPanelProps {
|
||||
simulationId: number;
|
||||
}
|
||||
|
||||
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
const query = useC2Tasks(simulationId, { enabled: true });
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const tasks = query.data?.tasks ?? [];
|
||||
const isRefreshing = query.isFetching && !query.isLoading;
|
||||
|
||||
function toggleExpand(id: number, completed: boolean) {
|
||||
if (!completed) return;
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="c2-tasks-panel"
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
|
||||
{isRefreshing && (
|
||||
<span
|
||||
data-testid="c2-task-refresh-indicator"
|
||||
className="text-[12px] text-graphite"
|
||||
>
|
||||
Refreshing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="border border-hairline rounded-none px-md py-md">
|
||||
<p className="text-[14px] text-graphite">
|
||||
No C2 tasks yet. Use Execute via C2 to launch commands.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" />
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Task</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Source</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const isExpanded = expandedIds.has(task.id);
|
||||
const canExpand = task.completed && Boolean(task.output);
|
||||
return (
|
||||
<Fragment key={task.id}>
|
||||
<tr
|
||||
data-testid="c2-task-row"
|
||||
onClick={() => toggleExpand(task.id, task.completed)}
|
||||
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud' : ''}`}
|
||||
>
|
||||
<td className="px-md py-sm text-graphite">
|
||||
{canExpand ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
) : (
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">#{task.mythic_task_display_id}</td>
|
||||
<td
|
||||
className="px-md py-sm font-mono max-w-[200px] truncate"
|
||||
title={task.command}
|
||||
>
|
||||
{task.command}
|
||||
</td>
|
||||
<td className="px-md py-sm">
|
||||
<span className="inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold bg-cloud text-graphite border border-hairline">
|
||||
{task.mapping_applied ? 'MIMIC' : 'IMPORT'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm">
|
||||
<C2TaskStatusBadge status={task.status} />
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono text-graphite">
|
||||
{task.completed_at ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && task.output && (
|
||||
<tr className="border-b border-hairline bg-cloud">
|
||||
<td colSpan={6} className="px-md py-sm">
|
||||
<pre
|
||||
data-testid="c2-task-output"
|
||||
className="font-mono text-[12px] whitespace-pre-wrap text-ink"
|
||||
>
|
||||
{task.output}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user