import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useState, useEffect, useCallback } from "react"; import { Loader2, AlertCircle, ArrowLeft } from "lucide-react"; import { getTestById, updateTestRed, updateTestBlue, startExecution, submitRedEvidence, submitBlueEvidence, validateAsRedLead, validateAsBlueLead, reopenTest, pauseTimer, resumeTimer, getTestTimeline, getRetestChain, } from "../api/tests"; import { uploadEvidence, getEvidence } from "../api/evidence"; import { useAuth } from "../context/AuthContext"; import type { TestResult, TeamSide, TestTimelineEntry } from "../types/models"; import TestDetailHeader from "../components/test-detail/TestDetailHeader"; import TeamTabs from "../components/test-detail/TeamTabs"; import ValidationModal from "../components/test-detail/ValidationModal"; import ConfirmDialog from "../components/ConfirmDialog"; import JiraLinkPanel from "../components/JiraLinkPanel"; import WorklogTimeline from "../components/WorklogTimeline"; // ── Page Component ───────────────────────────────────────────────── export default function TestDetailPage() { const { testId } = useParams<{ testId: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { user } = useAuth(); // ── State ────────────────────────────────────────────────────── const [validationModal, setValidationModal] = useState<{ open: boolean; side: "red" | "blue"; }>({ open: false, side: "red" }); const [confirmReopen, setConfirmReopen] = useState(false); const [redDraft, setRedDraft] = useState({ procedure_text: "", tool_used: "", attack_success: false, red_summary: "", }); const [blueDraft, setBlueDraft] = useState<{ detection_result: TestResult | ""; blue_summary: string; }>({ detection_result: "", blue_summary: "", }); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); // ── Queries ──────────────────────────────────────────────────── const { data: test, isLoading, error, } = useQuery({ queryKey: ["test", testId], queryFn: () => getTestById(testId!), enabled: !!testId, }); const { data: timeline = [], isLoading: isTimelineLoading, } = useQuery({ queryKey: ["test-timeline", testId], queryFn: () => getTestTimeline(testId!), enabled: !!testId, }); const { data: retestChain = [] } = useQuery({ queryKey: ["retest-chain", testId], queryFn: () => getRetestChain(testId!), enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0), }); // Hydrate drafts from test data useEffect(() => { if (test) { setRedDraft({ procedure_text: test.procedure_text || "", tool_used: test.tool_used || "", attack_success: test.attack_success ?? false, red_summary: test.red_summary || "", }); setBlueDraft({ detection_result: test.detection_result || "", blue_summary: test.blue_summary || "", }); } }, [test]); // ── Toast helper ─────────────────────────────────────────────── const showToast = useCallback((message: string, type: "success" | "error") => { setToast({ message, type }); setTimeout(() => setToast(null), 5000); }, []); /** Extract a user-friendly error message from Axios or generic errors. */ const extractError = useCallback((err: unknown): string => { if (err && typeof err === "object" && "response" in err) { const resp = (err as { response?: { data?: { detail?: string | { message?: string } } } }).response; const detail = resp?.data?.detail; if (typeof detail === "string") return detail; if (detail && typeof detail === "object" && "message" in detail) return (detail as { message: string }).message; } if (err instanceof Error) return err.message; return "An unexpected error occurred"; }, []); const invalidateAll = useCallback(() => { queryClient.invalidateQueries({ queryKey: ["test", testId] }); queryClient.invalidateQueries({ queryKey: ["test-timeline", testId] }); queryClient.invalidateQueries({ queryKey: ["techniques"] }); }, [queryClient, testId]); // ── Mutations ────────────────────────────────────────────────── // Red field save (auto-save on blur would come later; for now Save button or transitions) const saveRedMutation = useMutation({ mutationFn: () => updateTestRed(testId!, { procedure_text: redDraft.procedure_text || undefined, tool_used: redDraft.tool_used || undefined, attack_success: redDraft.attack_success, red_summary: redDraft.red_summary || undefined, }), onSuccess: () => { invalidateAll(); showToast("Red Team fields saved", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const saveBlueMutation = useMutation({ mutationFn: () => updateTestBlue(testId!, { detection_result: (blueDraft.detection_result as TestResult) || undefined, blue_summary: blueDraft.blue_summary || undefined, }), onSuccess: () => { invalidateAll(); showToast("Blue Team fields saved", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); // State transitions const startExecMutation = useMutation({ mutationFn: () => startExecution(testId!), onSuccess: () => { invalidateAll(); showToast("Test execution started", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const submitRedMutation = useMutation({ mutationFn: () => submitRedEvidence(testId!), onSuccess: () => { invalidateAll(); showToast("Submitted to Blue Team", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const submitBlueMutation = useMutation({ mutationFn: () => submitBlueEvidence(testId!), onSuccess: () => { invalidateAll(); showToast("Submitted for review", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const validateRedLeadMutation = useMutation({ mutationFn: (payload: { red_validation_status: "approved" | "rejected"; red_validation_notes?: string }) => validateAsRedLead(testId!, payload), onSuccess: () => { invalidateAll(); setValidationModal({ open: false, side: "red" }); showToast("Red Lead validation submitted", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const validateBlueLeadMutation = useMutation({ mutationFn: (payload: { blue_validation_status: "approved" | "rejected"; blue_validation_notes?: string }) => validateAsBlueLead(testId!, payload), onSuccess: () => { invalidateAll(); setValidationModal({ open: false, side: "blue" }); showToast("Blue Lead validation submitted", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const reopenMutation = useMutation({ mutationFn: () => reopenTest(testId!), onSuccess: () => { invalidateAll(); setConfirmReopen(false); showToast("Test reopened", "success"); }, onError: (err: unknown) => { setConfirmReopen(false); showToast(extractError(err), "error"); }, }); // Timer pause/resume const pauseTimerMutation = useMutation({ mutationFn: () => pauseTimer(testId!), onSuccess: () => { invalidateAll(); showToast("Timer paused", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); const resumeTimerMutation = useMutation({ mutationFn: () => resumeTimer(testId!), onSuccess: () => { invalidateAll(); showToast("Timer resumed", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); // Evidence upload const uploadMutation = useMutation({ mutationFn: ({ file, team }: { file: File; team: TeamSide }) => uploadEvidence(testId!, file, team), onSuccess: () => { invalidateAll(); showToast("Evidence uploaded", "success"); }, onError: (err: unknown) => showToast(extractError(err), "error"), }); // ── Handlers ─────────────────────────────────────────────────── const handleDownload = async (evidenceId: string) => { try { const ev = await getEvidence(evidenceId); if (ev.download_url) { window.open(ev.download_url, "_blank"); } } catch (err) { console.error("Failed to get download URL:", err); } }; const handleRedFieldChange = (field: string, value: string | boolean) => { setRedDraft((prev) => ({ ...prev, [field]: value })); }; const handleBlueFieldChange = (field: string, value: string) => { setBlueDraft((prev) => ({ ...prev, [field]: value })); }; const handleUploadEvidence = async (file: File, team: TeamSide) => { await uploadMutation.mutateAsync({ file, team }); }; const handleValidationSubmit = ( side: "red" | "blue", status: "approved" | "rejected", notes: string, ) => { if (side === "red") { validateRedLeadMutation.mutate({ red_validation_status: status, red_validation_notes: notes || undefined, }); } else { validateBlueLeadMutation.mutate({ blue_validation_status: status, blue_validation_notes: notes || undefined, }); } }; const isTransitioning = startExecMutation.isPending || submitRedMutation.isPending || submitBlueMutation.isPending || reopenMutation.isPending; // ── Loading / Error states ───────────────────────────────────── if (isLoading) { return (
Failed to load test