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

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

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