/** * TestPhaseTimeline * * Read-only timeline of automated phase durations, with Tempo sync status and * a manual "Sync to Tempo" button for the Red Team Execution phase. * * 1. Red Team Execution (red_started_at → blue_started_at, minus paused) * 2. Blue Queue (blue_started_at → blue_work_started_at) * 3. Blue Evaluation (blue_work_started_at → …) * 4. Red Lead Validation (red_validated_at + status) * 5. Blue Lead Validation (blue_validated_at + status) */ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Clock, Sword, Shield, CheckCircle, XCircle, Timer, RefreshCw, Loader2, } from "lucide-react"; import type { Test } from "../types/models"; import { listTestWorklogs, type Worklog } from "../api/worklogs"; import { syncTestToTempo } from "../api/tests"; // ── Helpers ────────────────────────────────────────────────────────── function parseDate(s: string): Date { return new Date(s.endsWith("Z") ? s : s + "Z"); } function durationSeconds(start: string, end: string, pausedSecs = 0): number { const ms = parseDate(end).getTime() - parseDate(start).getTime(); return Math.max(0, Math.floor(ms / 1000) - pausedSecs); } function fmtDuration(secs: number): string { if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m`; const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); return m > 0 ? `${h}h ${m}m` : `${h}h`; } function fmtTs(s: string): string { return parseDate(s).toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } // ── Phase row ───────────────────────────────────────────────────────── interface PhaseRowProps { dotClass: string; icon: React.ReactNode; label: string; badge?: React.ReactNode; startTs: string | null; duration?: number | null; isLast?: boolean; extra?: React.ReactNode; } function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast, extra }: PhaseRowProps) { return (
{!isLast && (
)}
{icon} {label} {badge} {duration != null && ( {fmtDuration(duration)} )}
{startTs && (

{fmtTs(startTs)}

)} {extra &&
{extra}
}
); } // ── Validation badge ────────────────────────────────────────────────── function ValidationBadge({ status }: { status: string | null }) { if (!status) return null; if (status === "approved") return ( Approved ); if (status === "rejected") return ( Rejected ); return ( {status} ); } // ── Tempo sync badge / button ───────────────────────────────────────── function TempoSyncBadge({ worklog, testId, onSynced, }: { worklog: Worklog | undefined; testId: string; onSynced: () => void; }) { const [toast, setToast] = useState(null); const mutation = useMutation({ mutationFn: () => syncTestToTempo(testId), onSuccess: (data) => { const r = data.results[0]; if (r?.status === "synced") { setToast("Synced to Tempo ✓"); onSynced(); } else if (r?.status === "already_synced") { setToast("Already synced"); } else { setToast(r?.detail ?? "Skipped — check Tempo settings"); } setTimeout(() => setToast(null), 4000); }, onError: (e: unknown) => { const msg = e && typeof e === "object" && "response" in e ? ((e as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Sync failed") : "Sync failed"; setToast(msg); setTimeout(() => setToast(null), 5000); }, }); // Already synced if (worklog?.tempo_synced) { return ( Tempo ); } // Not synced — show button return (
{toast && ( {toast} )}
); } // ── Main component ──────────────────────────────────────────────────── interface TestPhaseTimelineProps { test: Test; testId?: string; } export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelineProps) { const queryClient = useQueryClient(); const { red_started_at, blue_started_at, blue_work_started_at, red_paused_seconds, blue_paused_seconds, red_validated_at, blue_validated_at, red_validation_status, blue_validation_status, } = test; // Fetch worklogs when testId is provided const { data: worklogs = [] } = useQuery({ queryKey: ["worklogs", "test", testId], queryFn: () => listTestWorklogs(testId!), enabled: !!testId, }); const redWorklog = worklogs.find((w) => w.activity_type === "red_team_execution"); const refreshWorklogs = () => { if (testId) queryClient.invalidateQueries({ queryKey: ["worklogs", "test", testId] }); }; // Compute per-phase durations const redExecSecs = red_started_at && blue_started_at ? durationSeconds(red_started_at, blue_started_at, red_paused_seconds) : null; const blueQueueSecs = blue_started_at && blue_work_started_at ? durationSeconds(blue_started_at, blue_work_started_at) : null; const blueEvalEnd = red_validated_at || blue_validated_at || null; const blueEvalSecs = blue_work_started_at && blueEvalEnd ? durationSeconds(blue_work_started_at, blueEvalEnd, blue_paused_seconds) : null; const hasRedExec = !!red_started_at; const hasBlueQueue = !!blue_started_at; const hasBlueEval = !!blue_work_started_at; const hasRedValidation = !!red_validated_at; const hasBlueValidation = !!blue_validated_at; const anyPhase = hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation; if (!anyPhase) return null; const phases = [hasRedExec, hasBlueQueue, hasBlueEval, hasRedValidation, hasBlueValidation]; const lastIdx = phases.lastIndexOf(true); let phaseIdx = -1; return (

Phase Timeline

{hasRedExec && ( } label="Red Team Execution" startTs={red_started_at} duration={redExecSecs} isLast={++phaseIdx === lastIdx} extra={ testId ? ( ) : undefined } /> )} {hasBlueQueue && ( } label="Blue Queue" startTs={blue_started_at} duration={blueQueueSecs} isLast={++phaseIdx === lastIdx} /> )} {hasBlueEval && ( } label="Blue Evaluation" startTs={blue_work_started_at} duration={blueEvalSecs} isLast={++phaseIdx === lastIdx} /> )} {hasRedValidation && ( } label="Red Lead Validation" badge={} startTs={red_validated_at} duration={null} isLast={++phaseIdx === lastIdx} /> )} {hasBlueValidation && ( } label="Blue Lead Validation" badge={} startTs={blue_validated_at} duration={null} isLast={++phaseIdx === lastIdx} /> )}
); }