Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend (reopen_test): - Preserve red/blue validation NOTES — teams see exactly what to fix without losing the rejection context. Previously both notes were cleared. - Preserve all content fields: procedure_text, tool_used, red_summary, attack_success, blue_summary, detection_result (already the case). - Preserve evidences (separate table, unaffected — already the case). - Still clear: validation statuses + who/when validated (fresh re-validation required). Phase timing reset so the new execution starts clean. Frontend: - Button label: 'Reopen Test' → 'Continue Test' (more accurate intent) - Dialog title: 'Reopen Test' → 'Continue Test' - Dialog message: replaces alarming 'workflow will be restarted / clear all' with accurate description of what is preserved vs reset - Toast: explains what to do next
411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
import {
|
|
FlaskConical,
|
|
Play,
|
|
Send,
|
|
CheckCircle,
|
|
XCircle,
|
|
RotateCcw,
|
|
Loader2,
|
|
Shield,
|
|
ShieldCheck,
|
|
} from "lucide-react";
|
|
import type { Test, TestState, User } from "../../types/models";
|
|
import LiveTimer from "./LiveTimer";
|
|
|
|
// ── Progress steps ─────────────────────────────────────────────────
|
|
|
|
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
|
{ key: "draft", label: "Draft" },
|
|
{ key: "red_executing", label: "Red Exec" },
|
|
{ key: "blue_evaluating", label: "Blue Eval" },
|
|
{ key: "in_review", label: "Review" },
|
|
{ key: "validated", label: "Validated" },
|
|
];
|
|
|
|
const STATE_INDEX: Record<TestState, number> = {
|
|
draft: 0,
|
|
red_executing: 1,
|
|
blue_evaluating: 2,
|
|
in_review: 3,
|
|
validated: 4,
|
|
rejected: -1,
|
|
};
|
|
|
|
// ── Badge colors ───────────────────────────────────────────────────
|
|
|
|
const STATE_BADGE: Record<TestState, string> = {
|
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
};
|
|
|
|
// ── Props ──────────────────────────────────────────────────────────
|
|
|
|
interface TestDetailHeaderProps {
|
|
test: Test;
|
|
user: User | null;
|
|
isTransitioning: boolean;
|
|
onStartExecution: () => void;
|
|
onSubmitRed: () => void;
|
|
onSubmitBlue: () => void;
|
|
onStartBlueWork: () => void;
|
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
|
onReopen: () => void;
|
|
onPauseTimer: () => void;
|
|
onResumeTimer: () => void;
|
|
isTogglingTimer: boolean;
|
|
}
|
|
|
|
// ── Component ──────────────────────────────────────────────────────
|
|
|
|
export default function TestDetailHeader({
|
|
test,
|
|
user,
|
|
isTransitioning,
|
|
onStartExecution,
|
|
onSubmitRed,
|
|
onSubmitBlue,
|
|
onStartBlueWork,
|
|
onOpenValidateModal,
|
|
onReopen,
|
|
onPauseTimer,
|
|
onResumeTimer,
|
|
isTogglingTimer,
|
|
}: TestDetailHeaderProps) {
|
|
const role = user?.role ?? "";
|
|
const currentIdx = STATE_INDEX[test.state];
|
|
|
|
const formatDate = (d: string | null) => {
|
|
if (!d) return null;
|
|
return new Date(d).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
};
|
|
|
|
// ── Contextual action buttons ────────────────────────────────────
|
|
|
|
const renderActions = () => {
|
|
const buttons: React.ReactNode[] = [];
|
|
|
|
// Red Team in draft -> Start Execution
|
|
if (
|
|
test.state === "draft" &&
|
|
(role === "red_tech" || role === "red_lead" || role === "admin")
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="start"
|
|
onClick={onStartExecution}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
|
Start Execution
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Red Team in red_executing -> Submit to Blue Team (requires ≥1 red evidence)
|
|
if (
|
|
test.state === "red_executing" &&
|
|
(role === "red_tech" || role === "red_lead" || role === "admin")
|
|
) {
|
|
const hasRedEvidence = (test.red_evidences?.length ?? 0) > 0;
|
|
buttons.push(
|
|
<div key="submit-red" className="flex flex-col items-end gap-1">
|
|
<button
|
|
onClick={onSubmitRed}
|
|
disabled={isTransitioning || !hasRedEvidence}
|
|
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
Submit to Blue Team
|
|
</button>
|
|
{!hasRedEvidence && (
|
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
|
)}
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
// Blue Team in blue_evaluating:
|
|
// - if not picked up yet: show "Start Evaluation" button
|
|
// - if already picked up: show "Submit for Review" button
|
|
if (
|
|
test.state === "blue_evaluating" &&
|
|
(role === "blue_tech" || role === "blue_lead" || role === "admin")
|
|
) {
|
|
if (!test.blue_work_started_at) {
|
|
buttons.push(
|
|
<button
|
|
key="start-blue-work"
|
|
onClick={onStartBlueWork}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
|
Start Evaluation
|
|
</button>,
|
|
);
|
|
} else {
|
|
// Submit for Review requires ≥1 blue evidence
|
|
const hasBlueEvidence = (test.blue_evidences?.length ?? 0) > 0;
|
|
buttons.push(
|
|
<div key="submit-blue" className="flex flex-col items-end gap-1">
|
|
<button
|
|
onClick={onSubmitBlue}
|
|
disabled={isTransitioning || !hasBlueEvidence}
|
|
title={!hasBlueEvidence ? "Upload at least one Blue Team evidence file before submitting" : undefined}
|
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
Submit for Review
|
|
</button>
|
|
{!hasBlueEvidence && (
|
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
|
)}
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Red Lead in in_review -> Validate Red
|
|
if (
|
|
test.state === "in_review" &&
|
|
(role === "red_lead" || role === "admin") &&
|
|
!test.red_validation_status
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="validate-red"
|
|
onClick={() => onOpenValidateModal("red")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors"
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
Validate Red Side
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Blue Lead in in_review -> Validate Blue
|
|
if (
|
|
test.state === "in_review" &&
|
|
(role === "blue_lead" || role === "admin") &&
|
|
!test.blue_validation_status
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="validate-blue"
|
|
onClick={() => onOpenValidateModal("blue")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
Validate Blue Side
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Leads/admin on rejected -> Reopen
|
|
if (
|
|
test.state === "rejected" &&
|
|
(role === "red_lead" || role === "blue_lead" || role === "admin")
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="reopen"
|
|
onClick={onReopen}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-900/20 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-900/40 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
|
|
Continue Test
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
return buttons.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
|
|
) : null;
|
|
};
|
|
|
|
// ── Dual validation indicators ───────────────────────────────────
|
|
|
|
const renderValidationIndicators = () => {
|
|
if (test.state !== "in_review" && test.state !== "validated" && test.state !== "rejected") {
|
|
return null;
|
|
}
|
|
|
|
const redStatus = test.red_validation_status;
|
|
const blueStatus = test.blue_validation_status;
|
|
|
|
const indicator = (label: string, status: string | null) => {
|
|
if (status === "approved")
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-green-400">
|
|
<CheckCircle className="h-3.5 w-3.5" /> {label}: Approved
|
|
</span>
|
|
);
|
|
if (status === "rejected")
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|
<XCircle className="h-3.5 w-3.5" /> {label}: Rejected
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-gray-500">
|
|
<ShieldCheck className="h-3.5 w-3.5" /> {label}: Pending
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-4">
|
|
{indicator("Red Lead", redStatus)}
|
|
{indicator("Blue Lead", blueStatus)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── Live timer ───────────────────────────────────────────────────
|
|
|
|
const canControlTimer =
|
|
(test.state === "red_executing" && (role === "red_tech" || role === "red_lead" || role === "admin")) ||
|
|
(test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead" || role === "admin"));
|
|
|
|
const renderLiveTimer = () => {
|
|
if (test.state === "red_executing" && test.red_started_at) {
|
|
return (
|
|
<LiveTimer
|
|
startedAt={test.red_started_at}
|
|
pausedAt={test.paused_at}
|
|
pausedSeconds={test.red_paused_seconds}
|
|
label="Red Team Timer"
|
|
variant="red"
|
|
onPause={onPauseTimer}
|
|
onResume={onResumeTimer}
|
|
canControl={canControlTimer}
|
|
isToggling={isTogglingTimer}
|
|
/>
|
|
);
|
|
}
|
|
if (test.state === "blue_evaluating" && test.blue_work_started_at) {
|
|
return (
|
|
<LiveTimer
|
|
startedAt={test.blue_work_started_at}
|
|
pausedAt={test.paused_at}
|
|
pausedSeconds={test.blue_paused_seconds}
|
|
label="Blue Team Timer"
|
|
variant="blue"
|
|
onPause={onPauseTimer}
|
|
onResume={onResumeTimer}
|
|
canControl={canControlTimer}
|
|
isToggling={isTogglingTimer}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// ── Render ───────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6 space-y-4">
|
|
{/* Top row: name + badge + actions */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="rounded-lg bg-cyan-500/10 p-3">
|
|
<FlaskConical className="h-8 w-8 text-cyan-400" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold text-white">{test.name}</h1>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${
|
|
STATE_BADGE[test.state]
|
|
}`}
|
|
>
|
|
{test.state.replace(/_/g, " ")}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Created {formatDate(test.created_at)}
|
|
</p>
|
|
{renderValidationIndicators()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
{renderLiveTimer()}
|
|
{renderActions()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
{test.state !== "rejected" && (
|
|
<div className="pt-2">
|
|
<div className="flex items-center gap-1">
|
|
{PROGRESS_STEPS.map((step, idx) => {
|
|
const isCompleted = idx < currentIdx;
|
|
const isCurrent = idx === currentIdx;
|
|
|
|
return (
|
|
<div key={step.key} className="flex flex-1 flex-col items-center gap-1">
|
|
<div className="flex w-full items-center">
|
|
{/* Connector left */}
|
|
{idx > 0 && (
|
|
<div
|
|
className={`h-0.5 flex-1 ${
|
|
isCompleted || isCurrent ? "bg-cyan-500" : "bg-gray-700"
|
|
}`}
|
|
/>
|
|
)}
|
|
{/* Dot */}
|
|
<div
|
|
className={`h-3 w-3 shrink-0 rounded-full border-2 ${
|
|
isCompleted
|
|
? "border-cyan-500 bg-cyan-500"
|
|
: isCurrent
|
|
? "border-cyan-500 bg-gray-900"
|
|
: "border-gray-600 bg-gray-900"
|
|
}`}
|
|
/>
|
|
{/* Connector right */}
|
|
{idx < PROGRESS_STEPS.length - 1 && (
|
|
<div
|
|
className={`h-0.5 flex-1 ${
|
|
isCompleted ? "bg-cyan-500" : "bg-gray-700"
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`text-[10px] font-medium ${
|
|
isCurrent ? "text-cyan-400" : isCompleted ? "text-gray-400" : "text-gray-600"
|
|
}`}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rejected banner */}
|
|
{test.state === "rejected" && (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
|
This test was rejected and needs to be reopened to restart the workflow.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|