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:
12
CHANGELOG.md
12
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/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.
|
- **`tasks/todo.md` M7 section** carries a dedicated "Amendement 2026-05-15" sub-block tracking the backend (☑) and frontend (☐) items.
|
||||||
|
|
||||||
#### Frontend (in progress)
|
#### Frontend (shipped)
|
||||||
- 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.
|
- **`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)
|
### 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:
|
- **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:
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ export interface MissionTest {
|
|||||||
executed_at_overridden: boolean;
|
executed_at_overridden: boolean;
|
||||||
mitre_tags: MissionMitreTag[];
|
mitre_tags: MissionMitreTag[];
|
||||||
source_test_template_id: string | null;
|
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 {
|
export interface MissionScenario {
|
||||||
@@ -223,6 +240,11 @@ export interface MissionTestDetail {
|
|||||||
blue_comment_md: string | null;
|
blue_comment_md: string | null;
|
||||||
detection_level_id: string | null;
|
detection_level_id: string | null;
|
||||||
detection_level_key: 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_id: string | null;
|
||||||
last_actor_email: string | null;
|
last_actor_email: string | null;
|
||||||
last_actor_display_name: string | null;
|
last_actor_display_name: string | null;
|
||||||
@@ -239,6 +261,11 @@ export interface UpdateMissionTestPayload {
|
|||||||
detection_level_id?: string | null;
|
detection_level_id?: string | null;
|
||||||
executed_at?: string | null;
|
executed_at?: string | null;
|
||||||
executed_at_overridden?: boolean;
|
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 {
|
export interface TestTransitionPayload {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { MarkdownField } from '@/components/MarkdownField';
|
||||||
import { Alert } from '@/components/ui/Alert';
|
import { Alert } from '@/components/ui/Alert';
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
MISSION_STATUS_ACCENT,
|
MISSION_STATUS_ACCENT,
|
||||||
MISSION_STATUS_LABEL,
|
MISSION_STATUS_LABEL,
|
||||||
missionKeys,
|
missionKeys,
|
||||||
|
missionTestKeys,
|
||||||
type AddScenariosPayload,
|
type AddScenariosPayload,
|
||||||
|
type DetectionLevelList,
|
||||||
type MemberPayload,
|
type MemberPayload,
|
||||||
type Mission,
|
type Mission,
|
||||||
type MissionRoleHint,
|
type MissionRoleHint,
|
||||||
@@ -25,6 +27,90 @@ import {
|
|||||||
type TransitionPayload,
|
type TransitionPayload,
|
||||||
type UpdateMissionPayload,
|
type UpdateMissionPayload,
|
||||||
} from '@/lib/missions';
|
} 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 {
|
import type {
|
||||||
ScenarioTemplate,
|
ScenarioTemplate,
|
||||||
ScenarioTemplateListResponse,
|
ScenarioTemplateListResponse,
|
||||||
@@ -625,112 +711,19 @@ export function MissionDetailPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{tab === 'tests' && (
|
{tab === 'tests' && (
|
||||||
<Card>
|
<FullBleedTests
|
||||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
mission={m}
|
||||||
<p className="font-mono text-2xs text-text-dim">
|
canEdit={canEdit}
|
||||||
Snapshots are frozen at append time — editing a source template
|
canWriteRed={
|
||||||
does not propagate.
|
state.user?.is_admin === true ||
|
||||||
</p>
|
state.user?.permissions.includes('mission.write_red_fields') === true
|
||||||
{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'
|
|
||||||
}
|
}
|
||||||
>
|
canWriteBlue={
|
||||||
{t.state}
|
state.user?.is_admin === true ||
|
||||||
</Tag>
|
state.user?.permissions.includes('mission.write_blue_fields') === true
|
||||||
</td>
|
}
|
||||||
</tr>
|
onAddScenarios={() => setAddScenarios(true)}
|
||||||
))}
|
/>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'members' && (
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`.
|
- **`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.
|
- **`/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
|
## 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`.
|
- **`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`.
|
||||||
|
|||||||
@@ -43,6 +43,36 @@ Rapport HTML : `e2e/playwright-report/`.
|
|||||||
- Une mission existante avec au moins **1 scenario** snapshotté contenant
|
- Une mission existante avec au moins **1 scenario** snapshotté contenant
|
||||||
**≥ 1 test** (voir `testing-m6.md` pour le chemin de création).
|
**≥ 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>`)
|
### 3.1 Page de test (`/missions/<id>/tests/<test_id>`)
|
||||||
|
|
||||||
1. Depuis `/missions/<id>`, onglet **tests**, cliquer une ligne (ou le nom du
|
1. Depuis `/missions/<id>`, onglet **tests**, cliquer une ligne (ou le nom du
|
||||||
|
|||||||
Reference in New Issue
Block a user