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

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