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:
Knacky
2026-05-15 14:51:28 +02:00
parent 447f15213a
commit 9fc78e0832
6 changed files with 775 additions and 109 deletions

View File

@@ -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 `<table>` 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/<id>/tests/<test_id>` 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:

View File

@@ -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 {

View File

@@ -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'
<FullBleedTests
mission={m}
canEdit={canEdit}
canWriteRed={
state.user?.is_admin === true ||
state.user?.permissions.includes('mission.write_red_fields') === true
}
>
{t.state}
</Tag>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</Card>
canWriteBlue={
state.user?.is_admin === true ||
state.user?.permissions.includes('mission.write_blue_fields') === true
}
onAddScenarios={() => setAddScenarios(true)}
/>
)}
{tab === 'members' && (

View 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>
);
}

View File

@@ -78,6 +78,21 @@ project: Metamorph
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
## 2026-05-15 — M7 amendement : 5 champs blue + vue tabulaire
- **`e.errors()` de Pydantic v2 embarque l'exception originale dans `ctx`** quand un `AfterValidator` lève. `jsonify(e.errors())` crash avec `TypeError: Object of type ValueError is not JSON serializable`. Fix project-wide : `e.errors(include_context=False, include_url=False)` — strippe le ctx et l'URL doc, garde le reste qui est déjà JSON-safe. Sed global sur `backend/app/api/*.py`. Mémo : si la stack ajoute un nouveau handler `except ValidationError as e:`, prendre le même pattern.
- **Pydantic `EmailStr` reste trop strict pour le projet** (lessons M2 captured this). Pour le destinataire d'alerte, j'ai utilisé `Annotated[str, AfterValidator(_validate_email_shape)]` avec une regex `^[^@\s]+@[^@\s]+\.[^@\s]+$`. Pas de validation TLD. Si un futur champ "production" exige de la rigueur, on aura besoin de deux types : `_InternalEmail` (permissive) et `_PublicEmail` (strict). Pas le cas aujourd'hui — outil interne.
- **Naïve datetime + `timestamptz` = piège silencieux**. Postgres interprète la datetime naïve dans la session TZ ; sur l'API la majorité des clients enverra `2026-05-15T11:00:00` sans `Z`. Réponse : `Annotated[datetime, AfterValidator(_ensure_aware_datetime)]` qui rejette `tzinfo is None` avec 400. Le front respecte déjà la convention (append `:00Z` au datetime-local). À reproduire sur tout nouveau champ `timestamptz` accepté en write.
- **Élargir `MissionTestView` (la vue nested dans `GET /missions/{id}`) est OK** tant qu'on garde la requête en O(1) — j'ai ajouté ~15 fields mais batch-load les détections + last-actor users en 2 queries totales, peu importe le nombre de tests. Sans le batch, c'était un classique N+1.
- **Pattern édition inline en table** :
- State `editingTestId` *au-dessus* du tableau (un seul row en édition à la fois sur toute la mission).
- `draft` localement à `MissionScenarioTable`, copié depuis le test à l'entrée d'édition.
- `draftDiff(test, draft)` retourne `null` si rien n'a changé (évite un `PUT` vide).
- `useEffect([editingTestId])` re-derive le draft seulement quand l'identité change (pas sur polling refetch — leçon M7 déjà capturée).
- `window.confirm` sur Esc-with-dirty et sur double-click d'une autre ligne avec dirty draft.
- **Full-bleed escape `max-w-page`** : `marginLeft: 'calc(50% - 50vw)'` + `marginRight: 'calc(50% - 50vw)'` + `width: '100vw'`. Pattern déjà inventé pour le picker MITRE en M4 ; testé OK pour 7 colonnes denses. À factoriser dans un composant `<FullBleed>` si on en a un 3ᵉ usage.
- **`detection_level` rendu en pill dans la cellule Commentaires** plutôt que comme 8ᵉ colonne : la spec listait 7 colonnes héritées d'Excel ; ajouter une 8ᵉ aurait cassé le mental model du user. Pill au-dessus du commentaire est plus naturel + économise l'espace horizontal.
## 2026-05-14 — M7 execution + evidence + activity
- **`logging.LogRecord` reserves `created`** — same trap as `name` (M3 lessons): `extra={"created": n}` raises `KeyError: "Attempt to overwrite 'created' in LogRecord"`. Pattern: prefix with the entity (`rows_created`). The `created` is the LogRecord timestamp, hence the conflict. Reserved-key cheatsheet (kept growing): `name, msg, args, levelname, levelno, pathname, filename, module, funcName, created, msecs, lineno, thread, threadName, process`.

View File

@@ -43,6 +43,36 @@ Rapport HTML : `e2e/playwright-report/`.
- Une mission existante avec au moins **1 scenario** snapshotté contenant
**≥ 1 test** (voir `testing-m6.md` pour le chemin de création).
### 3.0 Vue tabulaire (`/missions/<id>` — onglet tests, amendement 2026-05-15)
L'onglet **tests** rend désormais un tableau plein écran **par scénario**
(un row par test). Largeur pleine viewport (`max-w-page` échappé via la
recette `calc(50% - 50vw)` — même mécanisme que le picker MITRE).
**Colonnes** :
| Colonne | Read mode | Edit mode | Perm requise |
|---------|-----------|-----------|--------------|
| **Test** | nom + chips MITRE | (read-only) | — |
| **Procédure** | snapshot_objective / description tronqués 180 chars | (read-only) | — |
| **Exécution** | `executed_at` + `red_command` tronqué | datetime-local + textarea command | `mission.write_red_fields` |
| **Source de log** | `blue_log_source` | input texte (placeholder `EDR / Firewall / NDR …`) | `mission.write_blue_fields` |
| **Commentaires** | pill state + pill detection_level + commentaire | select detection_level + textarea commentaire | `mission.write_blue_fields` |
| **Logs SIEM** | `blue_siem_logs` tronqué 240 chars | textarea 6 lignes | `mission.write_blue_fields` |
| **Cyber Incident** | `incident_at` / `incident_number` / `incident_recipient_email` empilés | 3 inputs (datetime-local / texte / email) | `mission.write_blue_fields` |
| **Actions** | lien `open ↗` vers `/missions/<id>/tests/<test_id>` (full detail + evidence) | boutons `Save` / `Cancel` | — |
**Workflow d'édition** :
1. Double-clic sur une ligne → la ligne entre en mode édition (les cellules
deviennent des inputs). Une seule ligne en édition à la fois — un double-clic
sur une autre ligne propose `Discard unsaved changes?` si la précédente
est dirty.
2. **Esc** = cancel (prompt si dirty).
3. **Save** = `PUT /missions/{id}/tests/{test_id}` avec **uniquement les champs
modifiés**. Les cellules qu'un user ne peut pas écrire restent disabled ;
le serveur revalide quoi qu'il arrive (defense in depth).
4. Pour l'upload de preuves : cliquer `open ↗` qui ouvre la page détail
(zone Red / zone Blue + dropzone evidence).
### 3.1 Page de test (`/missions/<id>/tests/<test_id>`)
1. Depuis `/missions/<id>`, onglet **tests**, cliquer une ligne (ou le nom du