/** * 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`; } /** * Datetime helpers — zero timezone interpretation. The user types a * wall-clock value, the server stores it verbatim, and we display it back * unchanged. We never go through `new Date(...).toISOString()` because that * would shift the value by the browser's local offset on every render. */ function isoToInputValue(iso: string | null | undefined): string { // `2026-05-15T10:30:00+00:00` → `2026-05-15T10:30`. if (!iso) return ''; return iso.slice(0, 16); } function inputValueToIso(local: string): string | null { // `2026-05-15T10:30` → `2026-05-15T10:30:00Z`. The `Z` makes the ISO // unambiguous for the backend without applying any local-time shift. if (!local) return null; return `${local}:00Z`; } 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 ?? ''); // `executedAtInput` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local // input speaks — stored verbatim, displayed verbatim, no TZ shift on either // side. Conversion only happens at submit (append `:00Z`). const [executedAtInput, setExecutedAtInput] = useState( isoToInputValue(test.executed_at), ); const executedAtServer = isoToInputValue(test.executed_at); const dirty = command !== (test.red_command ?? '') || output !== (test.red_output ?? '') || comment !== (test.red_comment_md ?? '') || executedAtInput !== executedAtServer; // 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. useEffect(() => { setCommand(test.red_command ?? ''); setOutput(test.red_output ?? ''); setComment(test.red_comment_md ?? ''); setExecutedAtInput(isoToInputValue(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, }; // Any change to the executed_at field is implicitly a manual override — // the operator wouldn't have touched it otherwise. The override flag is // a backend-side bookkeeping detail and never surfaced in the UI. if (executedAtInput !== executedAtServer) { body.executed_at = inputValueToIso(executedAtInput); body.executed_at_overridden = executedAtInput !== ''; } save.mutate(body); } const apiErr = save.error instanceof ApiError ? save.error : null; const canEditExecutedAt = canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue'); return ( {apiErr && {apiErr.message}} {/* The execution timestamp anchors the blue team's log correlation, so it leads the form (cf. user feedback 2026-05-15). What the user types is what gets stored — no timezone interpretation. */}
setExecutedAtInput(e.target.value)} disabled={!canEditExecutedAt} className="mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright" data-testid="red-executed-at" />
setCommand(e.target.value)} disabled={!canWriteRed} className="font-mono" data-testid="red-command" placeholder="powershell -enc ..." />