/**
* 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}
/>
)}
);
}