114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|