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

@@ -1,11 +1,15 @@
import { apiClient } from './client';
import type {
C2CallbackHistoryResponse,
C2Config,
C2ConfigInput,
C2TestResult,
C2CallbacksResponse,
C2ExecuteInput,
C2ExecuteResponse,
C2ImportInput,
C2ImportResponse,
C2TasksResponse,
} from './types';
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
@@ -58,3 +62,33 @@ export async function executeC2(
);
return data;
}
export async function getC2Tasks(simulationId: number): Promise<C2TasksResponse> {
const { data } = await apiClient.get<C2TasksResponse>(
`/simulations/${simulationId}/c2/tasks`,
);
return data;
}
export async function listCallbackHistory(
engagementId: number,
callbackDisplayId: number,
params: { page: number; pageSize: number },
): Promise<C2CallbackHistoryResponse> {
const { data } = await apiClient.get<C2CallbackHistoryResponse>(
`/engagements/${engagementId}/c2/callbacks/${callbackDisplayId}/history`,
{ params: { page: params.page, page_size: params.pageSize } },
);
return data;
}
export async function importC2(
simulationId: number,
input: C2ImportInput,
): Promise<C2ImportResponse> {
const { data } = await apiClient.post<C2ImportResponse>(
`/simulations/${simulationId}/c2/import`,
input,
);
return data;
}

View File

@@ -187,7 +187,8 @@ export interface C2CallbacksResponse {
callbacks: C2Callback[];
}
export interface C2Task {
// Thin shape returned by the execute endpoint
export interface C2ExecuteTask {
id: number;
mythic_task_display_id: number;
command: string;
@@ -201,5 +202,52 @@ export interface C2ExecuteInput {
}
export interface C2ExecuteResponse {
tasks: C2Task[];
tasks: C2ExecuteTask[];
}
// Full shape returned by the tasks list endpoint (M3)
export interface C2TaskListItem {
id: number;
mythic_task_display_id: number;
callback_display_id: number;
command: string;
params: string | null;
status: string;
completed: boolean;
output: string | null;
mapping_applied: boolean;
created_at: string;
completed_at: string | null;
}
export interface C2TasksResponse {
tasks: C2TaskListItem[];
}
// Callback history (M4)
export interface C2HistoryTask {
display_id: number;
command: string;
status: string;
completed: boolean;
completed_at: string | null;
created_at: string;
}
export interface C2CallbackHistoryResponse {
tasks: C2HistoryTask[];
total: number;
page: number;
page_size: number;
}
// Import (M4)
export interface C2ImportInput {
callback_display_id: number;
task_display_ids: number[];
}
export interface C2ImportResponse {
imported: number;
skipped: number;
}

View File

@@ -0,0 +1,87 @@
import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types';
interface C2CallbackPickerProps {
callbacks: C2Callback[];
isLoading: boolean;
isError: boolean;
error: unknown;
selectedId: number | null;
onSelect: (id: number) => void;
rowTestId?: string;
}
export function C2CallbackPicker({
callbacks,
isLoading,
isError,
error,
selectedId,
onSelect,
rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element {
if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>;
}
if (isError) {
return (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')}
</p>
);
}
if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
}
return (
<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">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid={rowTestId}
onClick={() => onSelect(cb.display_id)}
className={`cursor-pointer border-b border-hairline ${
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</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 ${
cb.active
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-graphite border border-hairline'
}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{cb.last_checkin}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,27 @@
// Dedicated badge for Mythic task statuses — separate from simulation status badges.
// submitted / processed → primary-soft (in-flight)
// completed → success-soft
// error* / fail* → warn-soft (task-level issue, not system error)
// anything else → cloud / graphite (unknown/neutral)
interface C2TaskStatusBadgeProps {
status: string;
}
function badgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'completed') return 'bg-success-soft text-success';
if (s.startsWith('error') || s.startsWith('fail')) return 'bg-warn-soft text-warn';
if (s === 'submitted' || s === 'processed') return 'bg-primary-soft text-primary-deep';
return 'bg-cloud text-graphite border border-hairline';
}
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${badgeClass(status)}`}
>
{status}
</span>
);
}

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

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
import type { C2Callback } from '@/api/types';
import { C2CallbackPicker } from './C2CallbackPicker';
import { useToast } from '@/hooks/useToast';
interface ExecuteViaC2ModalProps {
@@ -11,11 +11,6 @@ interface ExecuteViaC2ModalProps {
onClose: () => void;
}
function formatCheckin(ts: string): string {
// Show ISO timestamp as-is — it's a data cell (font-mono)
return ts;
}
export function ExecuteViaC2Modal({
simulationId,
engagementId,
@@ -31,7 +26,7 @@ export function ExecuteViaC2Modal({
const [commands, setCommands] = useState(initialCommands);
const [submitError, setSubmitError] = useState<string | null>(null);
const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? [];
const callbacks = callbacksQuery.data?.callbacks ?? [];
const commandLines = commands
.split('\n')
@@ -70,70 +65,18 @@ export function ExecuteViaC2Modal({
Execute via C2
</h2>
{/* Callback table */}
{/* Callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
{callbacksQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading callbacks</p>
)}
{callbacksQuery.isError && (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
</p>
)}
{!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && (
<p className="text-[14px] text-graphite">No callbacks available.</p>
)}
{callbacks.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 text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid="c2-callback-row"
onClick={() => setSelectedId(cb.display_id)}
className={`cursor-pointer border-b border-hairline ${
isSelected
? 'bg-primary-soft'
: 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`badge-pill-${cb.active ? 'active' : 'inactive'}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{formatCheckin(cb.last_checkin)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedId}
onSelect={setSelectedId}
rowTestId="c2-callback-row"
/>
</div>
{/* Commands */}

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

View File

@@ -3,11 +3,19 @@ import {
deleteC2Config,
executeC2,
getC2Config,
getC2Tasks,
importC2,
listCallbackHistory,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
import type { C2ConfigInput, C2ExecuteInput } from '@/api/types';
import type {
C2ConfigInput,
C2ExecuteInput,
C2ImportInput,
C2TasksResponse,
} from '@/api/types';
function c2ConfigKey(engagementId: number) {
return ['c2-config', engagementId] as const;
@@ -17,6 +25,14 @@ function c2CallbacksKey(engagementId: number) {
return ['c2-callbacks', engagementId] as const;
}
function c2TasksKey(simulationId: number) {
return ['c2-tasks', simulationId] as const;
}
function c2HistoryKey(engagementId: number, callbackDisplayId: number, page: number, pageSize: number) {
return ['c2-history', engagementId, callbackDisplayId, page, pageSize] as const;
}
function simulationKey(id: number) {
return ['simulations', id] as const;
}
@@ -69,6 +85,22 @@ export function useC2Callbacks(engagementId: number | undefined, options?: { ena
});
}
export function useC2Tasks(simulationId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof simulationId === 'number' &&
!Number.isNaN(simulationId) &&
(options?.enabled !== false);
return useQuery({
queryKey: simulationId ? c2TasksKey(simulationId) : ['c2-tasks', 'none'],
queryFn: () => getC2Tasks(simulationId as number),
enabled,
// Poll every 2500 ms while any task is incomplete; stop when all done.
refetchInterval: (query: { state: { data?: C2TasksResponse } }) =>
query.state.data?.tasks?.some((t) => !t.completed) ? 2500 : false,
});
}
export function useExecuteC2(simulationId: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
@@ -76,6 +108,45 @@ export function useExecuteC2(simulationId: number, engagementId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] });
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
},
});
}
export function useC2CallbackHistory(
engagementId: number | undefined,
callbackDisplayId: number | null,
options?: { page?: number; pageSize?: number; enabled?: boolean },
) {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 25;
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
callbackDisplayId !== null &&
(options?.enabled !== false);
return useQuery({
queryKey:
engagementId && callbackDisplayId !== null
? c2HistoryKey(engagementId, callbackDisplayId, page, pageSize)
: ['c2-history', 'none'],
queryFn: () =>
listCallbackHistory(engagementId as number, callbackDisplayId as number, {
page,
pageSize,
}),
enabled,
});
}
export function useImportC2(simulationId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ImportInput) => importC2(simulationId, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
},
});
}

View File

@@ -12,7 +12,7 @@ import {
useTransitionSimulation,
useUpdateSimulation,
} from '@/hooks/useSimulations';
import { useC2Config } from '@/hooks/useC2';
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
@@ -20,6 +20,8 @@ import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel';
interface RedteamFormState {
name: string;
@@ -70,6 +72,13 @@ export function SimulationFormPage(): JSX.Element {
);
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
enabled: !isNew && canEditRT,
});
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0;
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
@@ -82,6 +91,7 @@ export function SimulationFormPage(): JSX.Element {
const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showC2Modal, setShowC2Modal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
useEffect(() => {
if (!isNew && detail.data) {
@@ -307,8 +317,10 @@ export function SimulationFormPage(): JSX.Element {
</div>
)}
{/* 2-column grid: RT left, SOC right. Stacks vertically below lg. */}
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
<div className="grid gap-xl lg:grid-cols-2 items-start">
{/* Left column: RT card + C2 tasks panel */}
<div className="flex flex-col gap-xl">
{/* Red Team card */}
<form
id="rt-form"
@@ -394,7 +406,7 @@ export function SimulationFormPage(): JSX.Element {
</FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs">
<div className="pt-xs flex items-center gap-md flex-wrap">
<button
type="button"
data-testid="c2-execute-btn"
@@ -403,10 +415,24 @@ export function SimulationFormPage(): JSX.Element {
>
Execute via C2
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
Import C2 history
</button>
</div>
)}
</form>
{/* C2 tasks panel — under RT card, same left column */}
{showTasksPanel && simulationId && (
<C2TasksPanel simulationId={simulationId} />
)}
</div>{/* end left column */}
{/* SOC card */}
<form
id="soc-form"
@@ -543,6 +569,14 @@ export function SimulationFormPage(): JSX.Element {
onClose={() => setShowC2Modal(false)}
/>
)}
{showImportModal && simulationId && typeof engagementId === 'number' && (
<ImportC2HistoryModal
simulationId={simulationId}
engagementId={engagementId}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}