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:
Knacky
2026-06-10 20:11:12 +02:00
parent 8f23f59601
commit 7ff153905b
13 changed files with 1437 additions and 75 deletions

View 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>
);
}