feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
113
frontend/src/components/SimulationList.tsx
Normal file
113
frontend/src/components/SimulationList.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
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();
|
||||
|
||||
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"
|
||||
>
|
||||
Nouvelle simulation
|
||||
</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"
|
||||
>
|
||||
Nouvelle simulation
|
||||
</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={() =>
|
||||
(window.location.href = `/engagements/${engagementId}/simulations/${sim.id}/edit`)
|
||||
}
|
||||
>
|
||||
<td className="px-xl py-md">
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sim.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{sim.mitre_technique_id ?? '—'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user