/** * Per-test execution page (M7). * * Two zones, mirror of the spec §M7: * - Red zone (red border) — command, output, markdown comment, mark-executed. * - Blue zone (cyan border) — detection level, markdown comment, evidence dropzone. * * State transitions are driven from a small button row in the header. The * "modified by X Ns ago" indicator polls `/missions/{id}/activity?since=…` * every 15s while the page is mounted (and the document is visible). * * Field-level permissions: * - Admins always see and write everything. * - `mission.write_red_fields` enables the red-side form. * - `mission.write_blue_fields` enables the blue-side form + uploads. * The server is the ultimate arbiter (PUT/POST will 403 if a side is forbidden); * the UI just disables inputs the user cannot write to reduce confusion. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { MarkdownField } from '@/components/MarkdownField'; import { Alert } from '@/components/ui/Alert'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; import { SectionHeader } from '@/components/ui/SectionHeader'; import { Tag } from '@/components/ui/Tag'; import { TextField } from '@/components/ui/TextField'; import { ApiError, apiDelete, apiFetch, apiGet, apiPost, apiPut, } from '@/lib/api'; import { useAuth } from '@/lib/auth'; import { EVIDENCE_ALLOWED_EXTENSIONS, EVIDENCE_MAX_BYTES, MISSION_TEST_STATE_ACCENT, MISSION_TEST_STATE_LABEL, VALID_TEST_TRANSITIONS, missionKeys, missionTestKeys, type ActivityResponse, type DetectionLevel, type DetectionLevelList, type MissionTestDetail, type MissionTestEvidence, type TestTransitionPayload, type UpdateMissionTestPayload, } from '@/lib/missions'; const POLL_INTERVAL_MS = 15_000; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } function formatRelative(iso: string | null | undefined): string { if (!iso) return 'never'; const then = new Date(iso).getTime(); const now = Date.now(); const seconds = Math.max(0, Math.floor((now - then) / 1000)); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } /** * Convert a UTC ISO datetime to the wall-clock `YYYY-MM-DDTHH:MM` string that * `` expects (and displays) in the operator's * timezone. The naive `new Date(iso).toISOString().slice(0, 16)` shortcut * round-trips through UTC and silently shifts the time by the local offset * every time the user types, making the hour un-editable in any non-UTC TZ. */ function isoToLocalInputValue(iso: string | null | undefined): string { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** Parse a `datetime-local` input value (already in wall-clock local time) * back into a UTC ISO string. Returns null if the input is empty/invalid. */ function localInputValueToIso(local: string): string | null { if (!local) return null; const d = new Date(local); if (Number.isNaN(d.getTime())) return null; return d.toISOString(); } function useMissionTest(missionId: string, testId: string) { return useQuery({ queryKey: missionTestKeys.detail(missionId, testId), queryFn: () => apiGet(`/missions/${missionId}/tests/${testId}`), enabled: !!missionId && !!testId, }); } function useDetectionLevels(enabled: boolean) { return useQuery({ queryKey: missionTestKeys.detectionLevels(), queryFn: () => apiGet('/detection-levels'), enabled, staleTime: 60_000, }); } // --------------------------------------------------------------------------- // // Activity indicator // // --------------------------------------------------------------------------- // function useActivityWatcher( missionId: string, testId: string, onTouched: () => void, ) { const lastServerTimeRef = useRef(null); const onTouchedRef = useRef(onTouched); onTouchedRef.current = onTouched; useEffect(() => { if (!missionId) return; let cancelled = false; async function poll() { try { const since = lastServerTimeRef.current; const url = `/missions/${missionId}/activity` + (since ? `?since=${encodeURIComponent(since)}` : ''); const res = await apiGet(url); if (cancelled) return; lastServerTimeRef.current = res.server_time; // We only care about activity on the same test (the badge is local). if (res.items.some((it) => it.test_id === testId)) { onTouchedRef.current(); } } catch { // Network blips are non-fatal — the badge just doesn't refresh. } } // Prime the timestamp without firing onTouched. void poll(); const handle = window.setInterval(() => { if (document.visibilityState === 'visible') void poll(); }, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(handle); }; }, [missionId, testId]); } // --------------------------------------------------------------------------- // // Red zone // // --------------------------------------------------------------------------- // interface RedZoneProps { test: MissionTestDetail; missionId: string; canWriteRed: boolean; } function RedZone({ test, missionId, canWriteRed }: RedZoneProps) { const qc = useQueryClient(); const [command, setCommand] = useState(test.red_command ?? ''); const [output, setOutput] = useState(test.red_output ?? ''); const [comment, setComment] = useState(test.red_comment_md ?? ''); const [override, setOverride] = useState(test.executed_at_overridden); // `executedAtLocal` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local // input speaks; we only convert to/from UTC ISO at the boundaries // (initial sync + submit). Doing the round-trip on every keystroke shifted // the hour by the local TZ offset and made the time field uneditable in // any non-UTC zone (cf. tasks/lessons.md M7 fix). const [executedAtLocal, setExecutedAtLocal] = useState( isoToLocalInputValue(test.executed_at), ); const dirty = command !== (test.red_command ?? '') || output !== (test.red_output ?? '') || comment !== (test.red_comment_md ?? '') || override !== test.executed_at_overridden || (override && executedAtLocal !== isoToLocalInputValue(test.executed_at)); // Sync local state only when the *identity* of the test changes (route // change). Polling refetches return a new object reference for the same // test_id; resetting on those would wipe whatever the user is mid-typing. // Successful saves push the new test through `setQueryData`, which is fine // — the saved fields already match local state. useEffect(() => { setCommand(test.red_command ?? ''); setOutput(test.red_output ?? ''); setComment(test.red_comment_md ?? ''); setOverride(test.executed_at_overridden); setExecutedAtLocal(isoToLocalInputValue(test.executed_at)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [test.id]); const save = useMutation({ mutationFn: (body: UpdateMissionTestPayload) => apiPut( `/missions/${missionId}/tests/${test.id}`, body, ), onSuccess: (next) => { qc.setQueryData(missionTestKeys.detail(missionId, test.id), next); qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) }); }, }); function submit() { const body: UpdateMissionTestPayload = { red_command: command.trim() || null, red_output: output.length === 0 ? null : output, red_comment_md: comment.trim() || null, }; if (override !== test.executed_at_overridden) { body.executed_at_overridden = override; } if (override) { body.executed_at = localInputValueToIso(executedAtLocal); } save.mutate(body); } const apiErr = save.error instanceof ApiError ? save.error : null; const canOverride = canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue'); return ( {apiErr && {apiErr.message}} {/* The execution timestamp is the anchor the blue team correlates their logs against, so it lives at the very top of the form (cf. user feedback 2026-05-15). */}
Executed at {test.executed_at ? ( {test.executed_at} {test.executed_at_overridden && ( · overridden )} ) : ( Not yet executed — use the "→ Executed" transition above to stamp. )}
{override && (
setExecutedAtLocal(e.target.value)} disabled={!canOverride} className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-xs text-text" data-testid="red-executed-at" /> Browser local time — server stores the UTC equivalent.
)}
setCommand(e.target.value)} disabled={!canWriteRed} className="font-mono" data-testid="red-command" placeholder="powershell -enc ..." />