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,252 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker';
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
import { useToast } from '@/hooks/useToast';
const PAGE_SIZE = 25;
interface ImportC2HistoryModalProps {
simulationId: number;
engagementId: number;
onClose: () => void;
}
export function ImportC2HistoryModal({
simulationId,
engagementId,
onClose,
}: ImportC2HistoryModalProps): JSX.Element {
const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
const importMutation = useImportC2(simulationId);
const [selectedCallbackId, setSelectedCallbackId] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [submitError, setSubmitError] = useState<string | null>(null);
const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, {
page,
pageSize: PAGE_SIZE,
enabled: selectedCallbackId !== null,
});
const historyTasks = historyQuery.data?.tasks ?? [];
const total = historyQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const callbacks = callbacksQuery.data?.callbacks ?? [];
function handleCallbackSelect(id: number) {
setSelectedCallbackId(id);
setPage(1);
setCheckedIds(new Set());
}
function toggleCheck(displayId: number) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(displayId)) {
next.delete(displayId);
} else {
next.add(displayId);
}
return next;
});
}
const canImport = checkedIds.size > 0 && selectedCallbackId !== null;
const onImport = async () => {
if (!canImport) return;
setSubmitError(null);
try {
const result = await importMutation.mutateAsync({
callback_display_id: selectedCallbackId,
task_display_ids: Array.from(checkedIds),
});
const msg =
result.skipped > 0
? `Imported ${result.imported} task(s), ${result.skipped} already attached`
: `Imported ${result.imported} task(s)`;
push(msg, 'success');
onClose();
} catch (err) {
setSubmitError(extractApiError(err, 'Could not import tasks'));
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-import-modal-title"
data-testid="c2-import-modal"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history
</h2>
{/* Step 1: callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedCallbackId}
onSelect={handleCallbackSelect}
rowTestId="c2-import-callback-row"
/>
</div>
{/* Step 2: history table (shown once a callback is selected) */}
{selectedCallbackId !== null && (
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">
Task history{' '}
{total > 0 && (
<span className="text-graphite font-normal">({total} total)</span>
)}
</span>
{historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p>
)}
{historyQuery.isError && (
<p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')}
</p>
)}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p>
)}
{historyTasks.length > 0 && (
<>
<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 w-8" aria-label="Select" />
<th className="px-md py-sm text-left font-medium text-ink">#</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">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th>
</tr>
</thead>
<tbody>
{historyTasks.map((task) => (
<tr
key={task.display_id}
data-testid="c2-history-row"
onClick={() => toggleCheck(task.display_id)}
className="cursor-pointer border-b border-hairline hover:bg-cloud"
>
<td className="px-md py-sm">
<input
type="checkbox"
data-testid="c2-history-row-checkbox"
checked={checkedIds.has(task.display_id)}
onChange={() => toggleCheck(task.display_id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 accent-primary"
/>
</td>
<td className="px-md py-sm font-mono">{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">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'}
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.created_at}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center gap-md text-[14px] text-graphite">
<button
type="button"
data-testid="c2-history-prev"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
type="button"
data-testid="c2-history-next"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
{checkedIds.size > 0 && (
<span className="ml-auto text-ink">
{checkedIds.size} selected
</span>
)}
</div>
</>
)}
</div>
)}
{submitError && (
<p role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</p>
)}
{/* Footer */}
<div className="flex items-center gap-md pt-xs">
<button
type="button"
data-testid="c2-import-submit-btn"
className="btn-primary"
onClick={onImport}
disabled={!canImport || importMutation.isPending}
>
{importMutation.isPending ? 'Importing…' : 'Import selected'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={importMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}