Files
Aegis/frontend/src/components/TestPhaseTimeline.tsx
kitos 0955f35015
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
fix(tempo,jira,tests,ui): fix 4 pending issues
- tempo: remove unsupported `workType` kwarg from create_worklog call;
  tempoapiclient v4 does not accept it → was causing every Tempo sync to fail
- tests: set created_at=datetime.utcnow() explicitly on test creation (both
  create_test and create_test_from_template) since the DB column has no
  server default, causing 'Created —' in the UI
- jira: remove duplicate Proof of Concept section from ticket description body;
  PoC already lives in customfield_10309, no need to repeat it in description
- ui: add TestPhaseTimeline component (read-only) showing RT execution time,
  blue queue time, blue evaluation time and lead validation timestamps derived
  from test phase timestamps; placed above WorklogTimeline in test detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:38:29 +02:00

247 lines
8.1 KiB
TypeScript

/**
* TestPhaseTimeline
*
* Read-only timeline showing the automated phase durations for a test:
* 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 → …, open-ended until validated)
* 4. Red Lead Validation (red_validated_at + status)
* 5. Blue Lead Validation (blue_validated_at + status)
*
* Only phases with at least a start timestamp are rendered.
*/
import { Clock, Sword, Shield, CheckCircle, XCircle, Timer } from "lucide-react";
import type { Test } from "../types/models";
// ── Helpers ──────────────────────────────────────────────────────────
/** Parse a backend datetime string (may lack 'Z') to a JS Date. */
function parseDate(s: string): Date {
return new Date(s.endsWith("Z") ? s : s + "Z");
}
/** Compute duration in seconds between two timestamps, minus any paused seconds. */
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);
}
/** Human-readable duration string. */
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`;
}
/** Short date + time label. */
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; // seconds; null → still running / unknown
isLast?: boolean;
}
function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: PhaseRowProps) {
return (
<div className="relative flex gap-3 py-2">
{/* Connector line */}
{!isLast && (
<div className="absolute left-[15px] top-[18px] bottom-0 w-px bg-gray-700/60" />
)}
{/* Dot */}
<div
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${dotClass}`}
style={{ marginLeft: "6px" }}
/>
{/* Content */}
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400">{icon}</span>
<span className="text-xs font-medium text-gray-200">{label}</span>
{badge}
{duration != null && (
<span className="ml-auto text-xs font-semibold text-cyan-400">
{fmtDuration(duration)}
</span>
)}
</div>
{startTs && (
<p className="mt-0.5 text-xs text-gray-500">{fmtTs(startTs)}</p>
)}
</div>
</div>
);
}
// ── Validation badge ──────────────────────────────────────────────────
function ValidationBadge({ status }: { status: string | null }) {
if (!status) return null;
if (status === "approved")
return (
<span className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-green-900/40 text-green-400">
<CheckCircle className="h-3 w-3" /> Approved
</span>
);
if (status === "rejected")
return (
<span className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-red-900/40 text-red-400">
<XCircle className="h-3 w-3" /> Rejected
</span>
);
return (
<span className="rounded px-1.5 py-0.5 text-xs bg-yellow-900/30 text-yellow-400">
{status}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────
interface TestPhaseTimelineProps {
test: Test;
}
export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
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;
// 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;
// Blue evaluation: blue_work_started_at → first validation timestamp
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;
// Determine which phases to show (need at least a start timestamp)
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;
// Count rendered phases for isLast detection
const phases = [
hasRedExec,
hasBlueQueue,
hasBlueEval,
hasRedValidation,
hasBlueValidation,
];
const lastIdx = phases.lastIndexOf(true);
let phaseIdx = -1;
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Timer className="h-5 w-5 text-cyan-400" />
Phase Timeline
</h2>
<div className="relative space-y-0">
{hasRedExec && (
<PhaseRow
dotClass="border-orange-500/60"
icon={<Sword className="h-3 w-3 text-orange-400 inline" />}
label="Red Team Execution"
startTs={red_started_at}
duration={redExecSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueQueue && (
<PhaseRow
dotClass="border-yellow-500/60"
icon={<Clock className="h-3 w-3 text-yellow-400 inline" />}
label="Blue Queue"
startTs={blue_started_at}
duration={blueQueueSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueEval && (
<PhaseRow
dotClass="border-indigo-500/60"
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
label="Blue Evaluation"
startTs={blue_work_started_at}
duration={blueEvalSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasRedValidation && (
<PhaseRow
dotClass="border-orange-400/60"
icon={<CheckCircle className="h-3 w-3 text-orange-400 inline" />}
label="Red Lead Validation"
badge={<ValidationBadge status={red_validation_status} />}
startTs={red_validated_at}
duration={null}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueValidation && (
<PhaseRow
dotClass="border-indigo-400/60"
icon={<CheckCircle className="h-3 w-3 text-indigo-400 inline" />}
label="Blue Lead Validation"
badge={<ValidationBadge status={blue_validation_status} />}
startTs={blue_validated_at}
duration={null}
isLast={++phaseIdx === lastIdx}
/>
)}
</div>
</div>
);
}