/** * 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 { 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`; } 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); const [executedAt, setExecutedAt] = useState(test.executed_at ?? ''); const dirty = command !== (test.red_command ?? '') || output !== (test.red_output ?? '') || comment !== (test.red_comment_md ?? '') || override !== test.executed_at_overridden || (override && executedAt !== (test.executed_at ?? '')); // Sync local state when a refetch lands a newer version of the test // (concurrent collaboration: blue's edit shouldn't blow away our local // unsaved changes, but a fresh load should). useEffect(() => { setCommand(test.red_command ?? ''); setOutput(test.red_output ?? ''); setComment(test.red_comment_md ?? ''); setOverride(test.executed_at_overridden); setExecutedAt(test.executed_at ?? ''); // We deliberately reset on every test reload — see comment above. }, [test]); 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) { const iso = executedAt ? new Date(executedAt).toISOString() : null; body.executed_at = iso; } 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}} setCommand(e.target.value)} disabled={!canWriteRed} className="font-mono" data-testid="red-command" placeholder="powershell -enc ..." />