2026-05-26 11:22:05 +02:00
|
|
|
import { Link, useNavigate } from 'react-router-dom';
|
2026-05-26 11:13:14 +02:00
|
|
|
import { extractApiError } from '@/api/client';
|
|
|
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
|
|
|
import { useEngagementSimulations } from '@/hooks/useSimulations';
|
|
|
|
|
import { LoadingState } from './LoadingState';
|
|
|
|
|
import { ErrorState } from './ErrorState';
|
|
|
|
|
import { EmptyState } from './EmptyState';
|
|
|
|
|
import { SimulationStatusBadge } from './SimulationStatusBadge';
|
|
|
|
|
|
|
|
|
|
interface SimulationListProps {
|
|
|
|
|
engagementId: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null): string {
|
|
|
|
|
if (!value) return '—';
|
|
|
|
|
return value.replace('T', ' ').slice(0, 16);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
|
|
|
|
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
|
|
|
|
const { canEditEngagements } = useAuth();
|
2026-05-26 11:22:05 +02:00
|
|
|
const navigate = useNavigate();
|
2026-05-26 11:13:14 +02:00
|
|
|
|
|
|
|
|
if (isLoading) return <LoadingState label="Loading simulations…" />;
|
|
|
|
|
|
|
|
|
|
if (isError) {
|
|
|
|
|
return (
|
|
|
|
|
<ErrorState
|
|
|
|
|
message={extractApiError(error, 'Could not load simulations')}
|
|
|
|
|
onRetry={() => refetch()}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data || data.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<EmptyState
|
|
|
|
|
title="No simulations yet"
|
|
|
|
|
description="Create the first simulation to start tracking red team tests."
|
|
|
|
|
action={
|
|
|
|
|
canEditEngagements ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/engagements/${engagementId}/simulations/new`}
|
|
|
|
|
className="btn-primary"
|
|
|
|
|
data-testid="new-simulation-btn"
|
|
|
|
|
>
|
2026-05-26 16:08:46 +02:00
|
|
|
New simulation
|
2026-05-26 11:13:14 +02:00
|
|
|
</Link>
|
|
|
|
|
) : undefined
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-md">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
|
|
|
|
|
{canEditEngagements ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/engagements/${engagementId}/simulations/new`}
|
|
|
|
|
className="btn-primary"
|
|
|
|
|
data-testid="new-simulation-btn"
|
|
|
|
|
>
|
2026-05-26 16:08:46 +02:00
|
|
|
New simulation
|
2026-05-26 11:13:14 +02:00
|
|
|
</Link>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="card-product overflow-hidden p-0">
|
|
|
|
|
<table className="w-full text-left">
|
|
|
|
|
<thead className="bg-cloud border-b border-hairline">
|
|
|
|
|
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
|
|
|
|
<th className="px-xl py-md">Name</th>
|
|
|
|
|
<th className="px-xl py-md">MITRE</th>
|
|
|
|
|
<th className="px-xl py-md">Status</th>
|
|
|
|
|
<th className="px-xl py-md">Executed at</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{data.map((sim) => (
|
|
|
|
|
<tr
|
|
|
|
|
key={sim.id}
|
|
|
|
|
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
|
|
|
|
onClick={() =>
|
2026-05-26 11:22:05 +02:00
|
|
|
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
|
2026-05-26 11:13:14 +02:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-xl py-md">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
|
|
|
|
className="text-ink font-medium hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{sim.name}
|
|
|
|
|
</Link>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-xl py-md text-charcoal text-[14px]">
|
feat(frontend): sprint 3 — multi-technique MITRE selection + matrix modal
- types: replace mitre_technique_id/name scalars with techniques:MitreTechnique[]
on Simulation; add MitreTactic/MitreMatrixTechnique/MitreMatrixSubtechnique;
SimulationPatchInput now uses technique_ids:string[]
- api/mitre.ts: add getMitreMatrix() → GET /api/mitre/matrix
- hooks/useMitre: add useMitreMatrix(enabled) with staleTime:Infinity
- MitreTechniquePicker: clean rewrite — onSelect(technique) one-shot, resets
input after selection, no incoming value props
- MitreTechniqueTag: chip component with id+name and × remove button
- MitreMatrixModal: tactic columns (220px fixed), expand/collapse subtechniques,
search filter (auto-expands parent on sub match), selection state, focus trap
(Tab wrap, Escape, search autofocus), backdrop click cancel, Apply N techniques
- MitreTechniquesField: orchestrates tags+picker+matrix with auto-save PATCH on
every add/remove/Apply, dedup guard, disabled read-only mode for SOC
- SimulationFormPage: swap MitreTechniquePicker for MitreTechniquesField; remove
technique state from RT form (techniques have independent auto-save cycle)
- SimulationList: MITRE column → T1059 +2 counter format, — when empty
- Tests: 84 passing (13 test files); new suites for Tag, Field, Modal;
MitreTechniquePicker + SimulationFormPage + SimulationList adapted to new API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 04:04:23 +02:00
|
|
|
{sim.techniques.length === 0
|
|
|
|
|
? '—'
|
|
|
|
|
: sim.techniques.length === 1
|
|
|
|
|
? sim.techniques[0].id
|
|
|
|
|
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
|
2026-05-26 11:13:14 +02:00
|
|
|
</td>
|
|
|
|
|
<td className="px-xl py-md">
|
|
|
|
|
<SimulationStatusBadge status={sim.status} />
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-xl py-md text-charcoal text-[14px]">
|
|
|
|
|
{formatDate(sim.executed_at)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|