feat(m7-amend): full-bleed scenario table with inline edit + docs
Frontend half of the 2026-05-15 amendment (backend shipped in 447f152).
- `MissionScenarioTable` component: per-scenario <table> with 7 cols
(Test | Procédure | Exécution | Source de log | Commentaires | Logs
SIEM | Cyber Incident) + Actions cell. Read mode truncates; double-
click toggles a row into edit mode where each cell becomes the right
control. detection_level lives inside the Commentaires cell as a
pill + select (no 8th column).
- MissionDetailPage Tests tab uses the new component, lifts
`editingTestId` so only one row across the whole mission is editable
at a time. Esc reverts (prompt if dirty), double-click on a different
row with a dirty draft also prompts.
- Full-bleed escape via `calc(50% - 50vw)` (same recipe as the M4 MITRE
picker). 7 dense columns breathe on wide screens, no horizontal scroll.
- `draftDiff(test, draft)` returns `null` when nothing changed → no PUT
on a no-op save. The diff carries only touched fields so the server's
per-field perm gate stays clean.
- Datetime semantics: both datetime-local inputs reuse the M7 verbatim
recipe (`iso.slice(0, 16)` + `${local}:00Z`), zero TZ shift.
Docs
- tasks/testing-m7.md §3.0 documents the column matrix + edit workflow.
- tasks/lessons.md captures the Pydantic ctx-serialisation pitfall, the
naïve-datetime guard, the table-edit pattern.
- CHANGELOG section moves "Frontend (in progress)" → "Frontend (shipped)"
and details the diff.
49 Playwright tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,23 @@ export interface MissionTest {
|
||||
executed_at_overridden: boolean;
|
||||
mitre_tags: MissionMitreTag[];
|
||||
source_test_template_id: string | null;
|
||||
// Annotation fields surfaced server-side post-M7 so the scenario table
|
||||
// renders in a single GET (cf. CHANGELOG 2026-05-15 amendment).
|
||||
red_command: string | null;
|
||||
red_output: string | null;
|
||||
red_comment_md: string | null;
|
||||
blue_comment_md: string | null;
|
||||
detection_level_id: string | null;
|
||||
detection_level_key: string | null;
|
||||
blue_log_source: string | null;
|
||||
blue_siem_logs: string | null;
|
||||
blue_incident_at: string | null;
|
||||
blue_incident_number: string | null;
|
||||
blue_incident_recipient_email: string | null;
|
||||
last_actor_id: string | null;
|
||||
last_actor_email: string | null;
|
||||
last_actor_display_name: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MissionScenario {
|
||||
@@ -223,6 +240,11 @@ export interface MissionTestDetail {
|
||||
blue_comment_md: string | null;
|
||||
detection_level_id: string | null;
|
||||
detection_level_key: string | null;
|
||||
blue_log_source: string | null;
|
||||
blue_siem_logs: string | null;
|
||||
blue_incident_at: string | null;
|
||||
blue_incident_number: string | null;
|
||||
blue_incident_recipient_email: string | null;
|
||||
last_actor_id: string | null;
|
||||
last_actor_email: string | null;
|
||||
last_actor_display_name: string | null;
|
||||
@@ -239,6 +261,11 @@ export interface UpdateMissionTestPayload {
|
||||
detection_level_id?: string | null;
|
||||
executed_at?: string | null;
|
||||
executed_at_overridden?: boolean;
|
||||
blue_log_source?: string | null;
|
||||
blue_siem_logs?: string | null;
|
||||
blue_incident_at?: string | null;
|
||||
blue_incident_number?: string | null;
|
||||
blue_incident_recipient_email?: string | null;
|
||||
}
|
||||
|
||||
export interface TestTransitionPayload {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { MarkdownField } from '@/components/MarkdownField';
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
MISSION_STATUS_ACCENT,
|
||||
MISSION_STATUS_LABEL,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type AddScenariosPayload,
|
||||
type DetectionLevelList,
|
||||
type MemberPayload,
|
||||
type Mission,
|
||||
type MissionRoleHint,
|
||||
@@ -25,6 +27,90 @@ import {
|
||||
type TransitionPayload,
|
||||
type UpdateMissionPayload,
|
||||
} from '@/lib/missions';
|
||||
import { MissionScenarioTable } from '@/pages/MissionScenarioTable';
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Tests tab — full-bleed scenario tables //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
interface FullBleedTestsProps {
|
||||
mission: Mission;
|
||||
canEdit: boolean;
|
||||
canWriteRed: boolean;
|
||||
canWriteBlue: boolean;
|
||||
onAddScenarios: () => void;
|
||||
}
|
||||
|
||||
function FullBleedTests({
|
||||
mission,
|
||||
canEdit,
|
||||
canWriteRed,
|
||||
canWriteBlue,
|
||||
onAddScenarios,
|
||||
}: FullBleedTestsProps) {
|
||||
// A single row across the whole mission is in edit mode at a time so the
|
||||
// user never juggles two unsaved drafts (spec §F6 amendement 2026-05-15).
|
||||
const [editingTestId, setEditingTestId] = useState<string | null>(null);
|
||||
const detectionLevels = useQuery({
|
||||
queryKey: missionTestKeys.detectionLevels(),
|
||||
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Double-click a row to edit · server is the perm arbiter ·
|
||||
snapshots stay frozen at append time.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={onAddScenarios}
|
||||
data-testid="mission-add-scenarios"
|
||||
>
|
||||
+ Add scenarios
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mission.scenarios.length === 0 ? (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios snapshotted yet.
|
||||
{canEdit && ' Click "Add scenarios" to append one.'}
|
||||
</p>
|
||||
) : (
|
||||
// Full-bleed escape from the layout's max-w-page (same recipe as
|
||||
// the MITRE picker). Lets the 7-column table breathe on wide
|
||||
// screens without forcing a horizontal scroll.
|
||||
<div
|
||||
className="px-[60px]"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
}}
|
||||
data-testid="mission-scenarios"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{mission.scenarios.map((sc) => (
|
||||
<MissionScenarioTable
|
||||
key={sc.id}
|
||||
missionId={mission.id}
|
||||
scenario={sc}
|
||||
detectionLevels={detectionLevels.data?.items ?? []}
|
||||
canWriteRed={canWriteRed}
|
||||
canWriteBlue={canWriteBlue}
|
||||
editingTestId={editingTestId}
|
||||
onEditRequest={setEditingTestId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type {
|
||||
ScenarioTemplate,
|
||||
ScenarioTemplateListResponse,
|
||||
@@ -625,112 +711,19 @@ export function MissionDetailPage() {
|
||||
</nav>
|
||||
|
||||
{tab === 'tests' && (
|
||||
<Card>
|
||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Snapshots are frozen at append time — editing a source template
|
||||
does not propagate.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={() => setAddScenarios(true)}
|
||||
data-testid="mission-add-scenarios"
|
||||
>
|
||||
+ Add scenarios
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{m.scenarios.length === 0 ? (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios snapshotted yet.
|
||||
{canEdit && ' Click "Add scenarios" to append one.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4" data-testid="mission-scenarios">
|
||||
{m.scenarios.map((sc) => (
|
||||
<div
|
||||
key={sc.id}
|
||||
className="rounded-md border border-border bg-bg-card p-3"
|
||||
data-testid={`mission-scenario-${sc.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Tag accent="cyan">#{sc.position + 1}</Tag>
|
||||
<p className="font-mono text-xs text-text-bright">
|
||||
{sc.snapshot_name}
|
||||
</p>
|
||||
</div>
|
||||
{sc.snapshot_description && (
|
||||
<p className="mb-2 font-mono text-2xs text-text-dim">
|
||||
{sc.snapshot_description}
|
||||
</p>
|
||||
)}
|
||||
<table className="w-full font-mono text-2xs">
|
||||
<thead>
|
||||
<tr className="text-text-dim uppercase tracking-wider2">
|
||||
<th className="text-left py-1">#</th>
|
||||
<th className="text-left py-1">Test</th>
|
||||
<th className="text-left py-1">MITRE</th>
|
||||
<th className="text-left py-1">OPSEC</th>
|
||||
<th className="text-left py-1">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sc.tests.map((t) => (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-t border-border/40 hover:bg-bg-base/60"
|
||||
data-testid={`mission-test-${t.id}`}
|
||||
>
|
||||
<td className="py-1 text-text-dim">{t.position + 1}</td>
|
||||
<td className="py-1 text-text-bright">
|
||||
<Link
|
||||
to={`/missions/${m.id}/tests/${t.id}`}
|
||||
className="hover:underline"
|
||||
data-testid={`mission-test-link-${t.id}`}
|
||||
>
|
||||
{t.snapshot_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.mitre_tags.map((tag) => (
|
||||
<Tag
|
||||
accent="cyan"
|
||||
key={`${tag.kind}-${tag.external_id}`}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1 text-text">
|
||||
{t.snapshot_opsec_level}
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<Tag
|
||||
accent={
|
||||
t.state === 'pending'
|
||||
? 'teal'
|
||||
: t.state === 'executed'
|
||||
? 'orange'
|
||||
: t.state === 'reviewed_by_blue'
|
||||
? 'green'
|
||||
: 'rose'
|
||||
}
|
||||
>
|
||||
{t.state}
|
||||
</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<FullBleedTests
|
||||
mission={m}
|
||||
canEdit={canEdit}
|
||||
canWriteRed={
|
||||
state.user?.is_admin === true ||
|
||||
state.user?.permissions.includes('mission.write_red_fields') === true
|
||||
}
|
||||
canWriteBlue={
|
||||
state.user?.is_admin === true ||
|
||||
state.user?.permissions.includes('mission.write_blue_fields') === true
|
||||
}
|
||||
onAddScenarios={() => setAddScenarios(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
|
||||
593
frontend/src/pages/MissionScenarioTable.tsx
Normal file
593
frontend/src/pages/MissionScenarioTable.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Full-bleed scenario table with inline edit (spec §F6, amendement 2026-05-15).
|
||||
*
|
||||
* One table per scenario. Double-click a row to enter edit mode — a *single*
|
||||
* row in edit mode at a time, Esc cancels, "Save" commits, double-clicking
|
||||
* another row while dirty prompts to save first.
|
||||
*
|
||||
* Cells in edit mode are gated by the viewer's red/blue perms:
|
||||
* - `Exécution` (executed_at + red_command) → mission.write_red_fields
|
||||
* - everything else → mission.write_blue_fields
|
||||
*
|
||||
* `detection_level` lives inside the `Commentaires` cell as a select +
|
||||
* pill, not as its own column (spec §F6).
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type KeyboardEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { ApiError, apiPut } from '@/lib/api';
|
||||
import {
|
||||
MISSION_TEST_STATE_ACCENT,
|
||||
MISSION_TEST_STATE_LABEL,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type DetectionLevel,
|
||||
type MissionScenario,
|
||||
type MissionTest,
|
||||
type MissionTestDetail,
|
||||
type UpdateMissionTestPayload,
|
||||
} from '@/lib/missions';
|
||||
|
||||
interface MissionScenarioTableProps {
|
||||
missionId: string;
|
||||
scenario: MissionScenario;
|
||||
detectionLevels: DetectionLevel[];
|
||||
canWriteRed: boolean;
|
||||
canWriteBlue: boolean;
|
||||
/** Currently-editing test id across the whole mission — managed by the
|
||||
* parent so opening edit on a row in scenario B closes any active edit
|
||||
* in scenario A. */
|
||||
editingTestId: string | null;
|
||||
onEditRequest: (testId: string | null) => void;
|
||||
}
|
||||
|
||||
// ----- helpers -------------------------------------------------------------
|
||||
|
||||
function isoToInput(iso: string | null): string {
|
||||
// Strip the time portion verbatim. No TZ shift (cf. M7 lessons).
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 16);
|
||||
}
|
||||
|
||||
function inputToIso(local: string): string | null {
|
||||
if (!local) return null;
|
||||
return `${local}:00Z`;
|
||||
}
|
||||
|
||||
function emptyToNull(s: string): string | null {
|
||||
const t = s.trim();
|
||||
return t === '' ? null : s;
|
||||
}
|
||||
|
||||
// ----- editable row state --------------------------------------------------
|
||||
|
||||
interface RowDraft {
|
||||
// Red fields
|
||||
executed_at: string; // local YYYY-MM-DDTHH:MM
|
||||
red_command: string;
|
||||
// Blue fields
|
||||
blue_log_source: string;
|
||||
blue_comment_md: string;
|
||||
detection_level_id: string;
|
||||
blue_siem_logs: string;
|
||||
blue_incident_at: string;
|
||||
blue_incident_number: string;
|
||||
blue_incident_recipient_email: string;
|
||||
}
|
||||
|
||||
function makeDraft(t: MissionTest): RowDraft {
|
||||
return {
|
||||
executed_at: isoToInput(t.executed_at),
|
||||
red_command: t.red_command ?? '',
|
||||
blue_log_source: t.blue_log_source ?? '',
|
||||
blue_comment_md: t.blue_comment_md ?? '',
|
||||
detection_level_id: t.detection_level_id ?? '',
|
||||
blue_siem_logs: t.blue_siem_logs ?? '',
|
||||
blue_incident_at: isoToInput(t.blue_incident_at),
|
||||
blue_incident_number: t.blue_incident_number ?? '',
|
||||
blue_incident_recipient_email: t.blue_incident_recipient_email ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function draftDiff(t: MissionTest, d: RowDraft): UpdateMissionTestPayload | null {
|
||||
const body: UpdateMissionTestPayload = {};
|
||||
const before = makeDraft(t);
|
||||
let dirty = false;
|
||||
if (d.executed_at !== before.executed_at) {
|
||||
body.executed_at = inputToIso(d.executed_at);
|
||||
body.executed_at_overridden = d.executed_at !== '';
|
||||
dirty = true;
|
||||
}
|
||||
if (d.red_command !== before.red_command) {
|
||||
body.red_command = emptyToNull(d.red_command);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_log_source !== before.blue_log_source) {
|
||||
body.blue_log_source = emptyToNull(d.blue_log_source);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_comment_md !== before.blue_comment_md) {
|
||||
body.blue_comment_md = emptyToNull(d.blue_comment_md);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.detection_level_id !== before.detection_level_id) {
|
||||
body.detection_level_id = d.detection_level_id || null;
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_siem_logs !== before.blue_siem_logs) {
|
||||
body.blue_siem_logs = d.blue_siem_logs === '' ? null : d.blue_siem_logs;
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_at !== before.blue_incident_at) {
|
||||
body.blue_incident_at = inputToIso(d.blue_incident_at);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_number !== before.blue_incident_number) {
|
||||
body.blue_incident_number = emptyToNull(d.blue_incident_number);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_recipient_email !== before.blue_incident_recipient_email) {
|
||||
body.blue_incident_recipient_email = emptyToNull(
|
||||
d.blue_incident_recipient_email,
|
||||
);
|
||||
dirty = true;
|
||||
}
|
||||
return dirty ? body : null;
|
||||
}
|
||||
|
||||
// ----- read-mode cell helpers ---------------------------------------------
|
||||
|
||||
function truncate(s: string | null, n: number): string {
|
||||
if (!s) return '—';
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
function ExecutionCell({ test }: { test: MissionTest }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<code className="font-mono text-3xs text-text-bright">
|
||||
{test.executed_at ?? '—'}
|
||||
</code>
|
||||
{test.red_command && (
|
||||
<code className="font-mono text-3xs text-text-dim whitespace-pre-wrap break-all">
|
||||
{truncate(test.red_command, 140)}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentairesCell({
|
||||
test,
|
||||
detectionLevels,
|
||||
}: {
|
||||
test: MissionTest;
|
||||
detectionLevels: DetectionLevel[];
|
||||
}) {
|
||||
const lvl = detectionLevels.find((l) => l.id === test.detection_level_id);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
||||
{MISSION_TEST_STATE_LABEL[test.state]}
|
||||
</Tag>
|
||||
{lvl && (
|
||||
<Tag accent={(lvl.color_token as 'red') ?? 'cyan'}>{lvl.label_en}</Tag>
|
||||
)}
|
||||
</div>
|
||||
{test.blue_comment_md && (
|
||||
<p className="font-mono text-3xs text-text whitespace-pre-wrap">
|
||||
{truncate(test.blue_comment_md, 220)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CyberIncidentCell({ test }: { test: MissionTest }) {
|
||||
if (
|
||||
!test.blue_incident_at &&
|
||||
!test.blue_incident_number &&
|
||||
!test.blue_incident_recipient_email
|
||||
) {
|
||||
return <span className="font-mono text-3xs text-text-dim">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-3xs">
|
||||
{test.blue_incident_at && (
|
||||
<code className="text-text-bright">{test.blue_incident_at}</code>
|
||||
)}
|
||||
{test.blue_incident_number && (
|
||||
<span className="text-text">{test.blue_incident_number}</span>
|
||||
)}
|
||||
{test.blue_incident_recipient_email && (
|
||||
<span className="text-text-dim">{test.blue_incident_recipient_email}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- table component -----------------------------------------------------
|
||||
|
||||
export function MissionScenarioTable({
|
||||
missionId,
|
||||
scenario,
|
||||
detectionLevels,
|
||||
canWriteRed,
|
||||
canWriteBlue,
|
||||
editingTestId,
|
||||
onEditRequest,
|
||||
}: MissionScenarioTableProps) {
|
||||
const qc = useQueryClient();
|
||||
const editingTest = scenario.tests.find((t) => t.id === editingTestId);
|
||||
const [draft, setDraft] = useState<RowDraft | null>(
|
||||
editingTest ? makeDraft(editingTest) : null,
|
||||
);
|
||||
const [apiErr, setApiErr] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft whenever the parent flips us into edit mode for a row
|
||||
// (or out of it). We never re-derive from `scenario.tests` mid-edit so a
|
||||
// polling refetch doesn't blow the user's typing.
|
||||
useEffect(() => {
|
||||
if (editingTest) {
|
||||
setDraft(makeDraft(editingTest));
|
||||
} else {
|
||||
setDraft(null);
|
||||
}
|
||||
setApiErr(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingTestId]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async ({
|
||||
testId,
|
||||
body,
|
||||
}: {
|
||||
testId: string;
|
||||
body: UpdateMissionTestPayload;
|
||||
}) =>
|
||||
apiPut<MissionTestDetail>(
|
||||
`/missions/${missionId}/tests/${testId}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
if (editingTestId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: missionTestKeys.detail(missionId, editingTestId),
|
||||
});
|
||||
}
|
||||
onEditRequest(null);
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
if (e instanceof ApiError) {
|
||||
const payload = e.payload as { message?: string } | undefined;
|
||||
setApiErr(
|
||||
payload?.message
|
||||
? `${e.message} — ${payload.message}`
|
||||
: e.message,
|
||||
);
|
||||
} else {
|
||||
setApiErr(String(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function tryEnterEdit(testId: string) {
|
||||
if (!canWriteRed && !canWriteBlue) return;
|
||||
if (editingTestId && editingTestId !== testId && draft && editingTest) {
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (diff) {
|
||||
const ok = window.confirm(
|
||||
'You have unsaved changes on another row. Discard them?',
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
onEditRequest(testId);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (draft && editingTest) {
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (diff) {
|
||||
const ok = window.confirm('Discard unsaved changes?');
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
onEditRequest(null);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (!editingTest || !draft) return;
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (!diff) {
|
||||
onEditRequest(null);
|
||||
return;
|
||||
}
|
||||
save.mutate({ testId: editingTest.id, body: diff });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers to set a single draft field without rewriting the whole object.
|
||||
function set<K extends keyof RowDraft>(key: K, value: RowDraft[K]) {
|
||||
setDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||
}
|
||||
|
||||
const colHeaderClass =
|
||||
'text-left py-2 px-2 font-mono text-3xs uppercase tracking-wider2 text-text-dim border-b border-border bg-bg-card';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border bg-bg-card"
|
||||
data-testid={`scenario-table-${scenario.id}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 px-3 py-2 border-b border-border">
|
||||
<Tag accent="cyan">#{scenario.position + 1}</Tag>
|
||||
<h3 className="font-mono text-sm text-text-bright">
|
||||
{scenario.snapshot_name}
|
||||
</h3>
|
||||
{scenario.snapshot_description && (
|
||||
<span className="font-mono text-2xs text-text-dim">
|
||||
· {scenario.snapshot_description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse font-mono text-2xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${colHeaderClass} w-[7rem]`}>Test</th>
|
||||
<th className={`${colHeaderClass} w-[14rem]`}>Procédure</th>
|
||||
<th className={`${colHeaderClass} w-[12rem]`}>Exécution</th>
|
||||
<th className={`${colHeaderClass} w-[10rem]`}>Source de log</th>
|
||||
<th className={`${colHeaderClass} w-[16rem]`}>Commentaires</th>
|
||||
<th className={`${colHeaderClass} w-[20rem]`}>Logs SIEM</th>
|
||||
<th className={`${colHeaderClass} w-[12rem]`}>Cyber Incident</th>
|
||||
<th className={`${colHeaderClass} w-[8rem]`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scenario.tests.map((t) => {
|
||||
const isEditing = editingTestId === t.id;
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-t border-border/40 align-top hover:bg-bg-base/40 cursor-pointer"
|
||||
onDoubleClick={() => tryEnterEdit(t.id)}
|
||||
data-testid={`row-${t.id}`}
|
||||
>
|
||||
<td className="py-2 px-2 text-text-bright">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{t.snapshot_name}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.mitre_tags.slice(0, 3).map((tag) => (
|
||||
<Tag
|
||||
accent="cyan"
|
||||
key={`${tag.kind}-${tag.external_id}`}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap">
|
||||
{truncate(
|
||||
t.snapshot_objective ?? t.snapshot_description ?? '',
|
||||
180,
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<ExecutionCell test={t} />
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text">
|
||||
{t.blue_log_source ?? '—'}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<CommentairesCell
|
||||
test={t}
|
||||
detectionLevels={detectionLevels}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap break-words">
|
||||
{truncate(t.blue_siem_logs, 240)}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<CyberIncidentCell test={t} />
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text-dim">
|
||||
<Link
|
||||
to={`/missions/${missionId}/tests/${t.id}`}
|
||||
className="text-cyan hover:underline"
|
||||
data-testid={`open-test-${t.id}`}
|
||||
>
|
||||
open ↗
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!draft) return null;
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-t border-border/40 align-top bg-bg-base/30"
|
||||
data-testid={`row-edit-${t.id}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<td className="py-2 px-2 text-text-bright">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{t.snapshot_name}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.mitre_tags.slice(0, 3).map((tag) => (
|
||||
<Tag
|
||||
accent="cyan"
|
||||
key={`${tag.kind}-${tag.external_id}`}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap">
|
||||
{t.snapshot_objective ?? t.snapshot_description ?? ''}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.executed_at}
|
||||
onChange={(e) => set('executed_at', e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text-bright"
|
||||
data-testid={`cell-executed-at-${t.id}`}
|
||||
/>
|
||||
<textarea
|
||||
value={draft.red_command}
|
||||
onChange={(e) => set('red_command', e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
rows={3}
|
||||
placeholder="command"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-red-command-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.blue_log_source}
|
||||
onChange={(e) => set('blue_log_source', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="EDR / Firewall / NDR …"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-log-source-${t.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<select
|
||||
value={draft.detection_level_id}
|
||||
onChange={(e) =>
|
||||
set('detection_level_id', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-detection-level-${t.id}`}
|
||||
>
|
||||
<option value="">— detection —</option>
|
||||
{detectionLevels.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.label_en} ({l.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={draft.blue_comment_md}
|
||||
onChange={(e) => set('blue_comment_md', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
rows={3}
|
||||
placeholder="blueteam comment (markdown)"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-blue-comment-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<textarea
|
||||
value={draft.blue_siem_logs}
|
||||
onChange={(e) => set('blue_siem_logs', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
rows={6}
|
||||
placeholder="raw SIEM log lines"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-siem-logs-${t.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.blue_incident_at}
|
||||
onChange={(e) => set('blue_incident_at', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-at-${t.id}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.blue_incident_number}
|
||||
onChange={(e) =>
|
||||
set('blue_incident_number', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="INC-2026-…"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-number-${t.id}`}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={draft.blue_incident_recipient_email}
|
||||
onChange={(e) =>
|
||||
set('blue_incident_recipient_email', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="soc@…"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-email-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={commit}
|
||||
disabled={save.isPending}
|
||||
data-testid={`cell-save-${t.id}`}
|
||||
>
|
||||
{save.isPending ? '…' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
accent="teal"
|
||||
variant="ghost"
|
||||
onClick={cancel}
|
||||
data-testid={`cell-cancel-${t.id}`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{apiErr && (
|
||||
<p className="font-mono text-3xs text-red whitespace-pre-wrap">
|
||||
{apiErr}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="px-3 py-1 font-mono text-3xs text-text-dim border-t border-border">
|
||||
Double-click a row to edit · Esc to cancel · "open ↗" for the full
|
||||
per-test page (evidence upload, full procedure)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user