From 9fc78e0832afe9a929edcb66ccc3a0dfee77d8f0 Mon Sep 17 00:00:00 2001 From: Knacky Date: Fri, 15 May 2026 14:51:28 +0200 Subject: [PATCH] feat(m7-amend): full-bleed scenario table with inline edit + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the 2026-05-15 amendment (backend shipped in 447f152). - `MissionScenarioTable` component: per-scenario 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) --- CHANGELOG.md | 12 +- frontend/src/lib/missions.ts | 27 + frontend/src/pages/MissionDetailPage.tsx | 207 ++++--- frontend/src/pages/MissionScenarioTable.tsx | 593 ++++++++++++++++++++ tasks/lessons.md | 15 + tasks/testing-m7.md | 30 + 6 files changed, 775 insertions(+), 109 deletions(-) create mode 100644 frontend/src/pages/MissionScenarioTable.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9070a1a..d664c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,16 @@ row per test) with double-click inline edit. - **`tasks/spec.md` amended** — §4 in-scope bullet on blue saisie now lists the 5 fields, §F6 describes the tabular UX (full-bleed, one table per scenario, double-click inline edit), §8 model bullet enumerates the new columns. Header carries a `revised: 2026-05-15` note pointing readers at the amendment. - **`tasks/todo.md` M7 section** carries a dedicated "Amendement 2026-05-15" sub-block tracking the backend (☑) and frontend (☐) items. -#### Frontend (in progress) -- Tabular layout per scenario, full screen width (escape `max-w-page` like the MITRE picker), columns `Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`. Double-click row → inline edit gated by red/blue perms. Per-test page kept for evidence upload. +#### Frontend (shipped) +- **`MissionScenarioTable` component** (`frontend/src/pages/MissionScenarioTable.tsx`): per-scenario `
` with 7 columns (`Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`) plus an `Actions` cell that links to the full per-test page. Read mode shows truncated values; double-click toggles a row into edit mode where each cell becomes the right input (text, textarea, datetime-local, select). The `detection_level` lives **inside the Commentaires cell** as a pill + select — no 8th column. +- **Single-row-edit invariant**: `editingTestId` state lives in `MissionDetailPage`'s tests tab so only one row across the whole mission is editable at a time. Double-clicking another row while dirty surfaces a `Discard unsaved changes?` prompt; Esc reverts; Save commits the diff. +- **Diff-only PUT**: `draftDiff(test, draft)` walks every field and only includes the ones that changed; submitting an unchanged form is a no-op `onEditRequest(null)`. Keeps the per-field perm gate on the server cleanly applicable. +- **Full-bleed layout**: the tests tab escapes the layout's `max-w-page` via the canonical `calc(50% - 50vw)` recipe (same as the M4 MITRE picker) so the 7-column table breathes on wide screens without horizontal scroll. +- **Per-test page kept** at `/missions//tests/` for evidence upload and the full procedure view — every row's "open ↗" link routes there. +- **Datetime semantics consistent**: the table's two datetime-local inputs (executed_at + blue_incident_at) reuse the M7 verbatim recipe (`iso.slice(0, 16)` + `${local}:00Z`), no TZ shift on read or write. + +#### Tests +- E2E: existing m6 + m7 specs unaffected (all 49 still green). The new table reuses the `mission-add-scenarios` testid for the modal trigger so the wizard test still works. The old `mission-test-${id}` rows are gone but were never wired into any e2e selector. ### Fixed (post-M7 UX feedback — evidence whitelist visibility) - **Evidence dropzone didn't tell the operator which extensions are accepted, and the OS file picker showed "All files"** (`frontend/src/pages/MissionTestPage.tsx`): an operator could spend the time picking a `.exe` only to receive a 400 back. Surfaced the whitelist in the UI: diff --git a/frontend/src/lib/missions.ts b/frontend/src/lib/missions.ts index 8e5251b..73f73ad 100644 --- a/frontend/src/lib/missions.ts +++ b/frontend/src/lib/missions.ts @@ -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 { diff --git a/frontend/src/pages/MissionDetailPage.tsx b/frontend/src/pages/MissionDetailPage.tsx index eefac1c..80c10cb 100644 --- a/frontend/src/pages/MissionDetailPage.tsx +++ b/frontend/src/pages/MissionDetailPage.tsx @@ -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(null); + const detectionLevels = useQuery({ + queryKey: missionTestKeys.detectionLevels(), + queryFn: () => apiGet('/detection-levels'), + staleTime: 60_000, + }); + + return ( + <> +
+

+ Double-click a row to edit · server is the perm arbiter · + snapshots stay frozen at append time. +

+ {canEdit && ( + + )} +
+ {mission.scenarios.length === 0 ? ( +

+ No scenarios snapshotted yet. + {canEdit && ' Click "Add scenarios" to append one.'} +

+ ) : ( + // 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. +
+
+ {mission.scenarios.map((sc) => ( + + ))} +
+
+ )} + + ); +} import type { ScenarioTemplate, ScenarioTemplateListResponse, @@ -625,112 +711,19 @@ export function MissionDetailPage() { {tab === 'tests' && ( - -
-

- Snapshots are frozen at append time — editing a source template - does not propagate. -

- {canEdit && ( - - )} -
- {m.scenarios.length === 0 ? ( -

- No scenarios snapshotted yet. - {canEdit && ' Click "Add scenarios" to append one.'} -

- ) : ( -
- {m.scenarios.map((sc) => ( -
-
- #{sc.position + 1} -

- {sc.snapshot_name} -

-
- {sc.snapshot_description && ( -

- {sc.snapshot_description} -

- )} -
- - - - - - - - - - - {sc.tests.map((t) => ( - - - - - - - - ))} - -
#TestMITREOPSECState
{t.position + 1} - - {t.snapshot_name} - - -
- {t.mitre_tags.map((tag) => ( - - {tag.external_id} - - ))} -
-
- {t.snapshot_opsec_level} - - - {t.state} - -
- - ))} - - )} - + setAddScenarios(true)} + /> )} {tab === 'members' && ( diff --git a/frontend/src/pages/MissionScenarioTable.tsx b/frontend/src/pages/MissionScenarioTable.tsx new file mode 100644 index 0000000..7bb459e --- /dev/null +++ b/frontend/src/pages/MissionScenarioTable.tsx @@ -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 ( +
+ + {test.executed_at ?? '—'} + + {test.red_command && ( + + {truncate(test.red_command, 140)} + + )} +
+ ); +} + +function CommentairesCell({ + test, + detectionLevels, +}: { + test: MissionTest; + detectionLevels: DetectionLevel[]; +}) { + const lvl = detectionLevels.find((l) => l.id === test.detection_level_id); + return ( +
+
+ + {MISSION_TEST_STATE_LABEL[test.state]} + + {lvl && ( + {lvl.label_en} + )} +
+ {test.blue_comment_md && ( +

+ {truncate(test.blue_comment_md, 220)} +

+ )} +
+ ); +} + +function CyberIncidentCell({ test }: { test: MissionTest }) { + if ( + !test.blue_incident_at && + !test.blue_incident_number && + !test.blue_incident_recipient_email + ) { + return ; + } + return ( +
+ {test.blue_incident_at && ( + {test.blue_incident_at} + )} + {test.blue_incident_number && ( + {test.blue_incident_number} + )} + {test.blue_incident_recipient_email && ( + {test.blue_incident_recipient_email} + )} +
+ ); +} + +// ----- 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( + editingTest ? makeDraft(editingTest) : null, + ); + const [apiErr, setApiErr] = useState(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( + `/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(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 ( +
+
+ #{scenario.position + 1} +

+ {scenario.snapshot_name} +

+ {scenario.snapshot_description && ( + + · {scenario.snapshot_description} + + )} +
+ +
+ + + + + + + + + + + + + + + {scenario.tests.map((t) => { + const isEditing = editingTestId === t.id; + if (!isEditing) { + return ( + tryEnterEdit(t.id)} + data-testid={`row-${t.id}`} + > + + + + + + + + + + ); + } + if (!draft) return null; + return ( + + + +
TestProcédureExécutionSource de logCommentairesLogs SIEMCyber IncidentActions
+
+ {t.snapshot_name} +
+ {t.mitre_tags.slice(0, 3).map((tag) => ( + + {tag.external_id} + + ))} +
+
+
+ {truncate( + t.snapshot_objective ?? t.snapshot_description ?? '', + 180, + )} + + + + {t.blue_log_source ?? '—'} + + + + {truncate(t.blue_siem_logs, 240)} + + + + + open ↗ + +
+
+ {t.snapshot_name} +
+ {t.mitre_tags.slice(0, 3).map((tag) => ( + + {tag.external_id} + + ))} +
+
+
+ {t.snapshot_objective ?? t.snapshot_description ?? ''} + +
+ 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}`} + /> +