feat(tests): archive round history on reopen, stop pausing from setting Jira On Hold
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Reopening a test for rework (red or blue) now archives the round that's ending into a new test_round_history table before resetting the live fields — procedure/results/detection data is preserved instead of overwritten, and Phase Timeline renders every past round alongside the current one, so Blue Team keeps visibility into earlier Red attempts. - Each archived round also posts a permanent Jira comment (push_round_archived) since Jira's custom fields only ever show the latest value once the next round resubmits — the comment thread is what preserves full history there. - push_pause_event no longer transitions the Jira issue to "On Hold" — a timer pause is not a real Hold, and flipping Jira status for it was misleading. Only the explicit Hold action (push_hold_event) does that now.
This commit is contained in:
@@ -12,9 +12,9 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Clock, Sword, Shield, CheckCircle, XCircle, Timer,
|
||||
Clock, Sword, Shield, CheckCircle, XCircle, Timer, History,
|
||||
} from "lucide-react";
|
||||
import type { Test } from "../types/models";
|
||||
import type { Test, TestRoundHistory } from "../types/models";
|
||||
import { listTestWorklogs, type Worklog } from "../api/worklogs";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -130,6 +130,54 @@ function TempoSyncBadge({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archived round card ──────────────────────────────────────────────
|
||||
|
||||
function ArchivedRoundCard({ round }: { round: TestRoundHistory }) {
|
||||
const duration =
|
||||
round.started_at && round.ended_at
|
||||
? durationSeconds(round.started_at, round.ended_at, round.paused_seconds)
|
||||
: null;
|
||||
|
||||
const isRed = round.team === "red";
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-lg border border-gray-800 bg-gray-950/60 p-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<History className="h-3.5 w-3.5 text-gray-500" />
|
||||
<span className="text-xs font-semibold text-gray-300">
|
||||
{isRed ? "Red" : "Blue"} Round {round.round_number} (archived)
|
||||
</span>
|
||||
{duration != null && (
|
||||
<span className="ml-auto text-xs font-semibold text-gray-500">
|
||||
{fmtDuration(duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{round.started_at && (
|
||||
<p className="mt-0.5 text-xs text-gray-600">{fmtTs(round.started_at)}</p>
|
||||
)}
|
||||
<div className="mt-1.5 space-y-0.5 text-xs text-gray-400">
|
||||
{isRed ? (
|
||||
<>
|
||||
{round.attack_success && <p>Attack success: <span className="text-gray-300">{round.attack_success}</span></p>}
|
||||
{round.red_summary && <p className="text-gray-500">{round.red_summary}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{round.detection_result && <p>Detection: <span className="text-gray-300">{round.detection_result}</span></p>}
|
||||
{round.blue_summary && <p className="text-gray-500">{round.blue_summary}</p>}
|
||||
</>
|
||||
)}
|
||||
{round.review_notes && (
|
||||
<p className="mt-1 border-l-2 border-gray-700 pl-2 text-gray-500">
|
||||
Reopened: {round.review_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────
|
||||
|
||||
interface TestPhaseTimelineProps {
|
||||
@@ -148,8 +196,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
||||
blue_validated_at,
|
||||
red_validation_status,
|
||||
blue_validation_status,
|
||||
red_round_number,
|
||||
blue_round_number,
|
||||
round_history,
|
||||
} = test;
|
||||
|
||||
const redArchivedRounds = (round_history ?? []).filter((r) => r.team === "red");
|
||||
const blueArchivedRounds = (round_history ?? []).filter((r) => r.team === "blue");
|
||||
|
||||
// Fetch worklogs when testId is provided
|
||||
const { data: worklogs = [] } = useQuery({
|
||||
queryKey: ["worklogs", "test", testId],
|
||||
@@ -183,7 +237,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
||||
const hasBlueValidation = !!blue_validated_at;
|
||||
|
||||
const anyPhase =
|
||||
hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation;
|
||||
hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation ||
|
||||
redArchivedRounds.length > 0 || blueArchivedRounds.length > 0;
|
||||
|
||||
if (!anyPhase) return null;
|
||||
|
||||
@@ -198,12 +253,20 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
||||
Phase Timeline
|
||||
</h2>
|
||||
|
||||
{redArchivedRounds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
{redArchivedRounds.map((round) => (
|
||||
<ArchivedRoundCard key={round.id} round={round} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"}
|
||||
startTs={red_started_at}
|
||||
duration={redExecSecs}
|
||||
isLast={++phaseIdx === lastIdx}
|
||||
@@ -215,6 +278,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
||||
/>
|
||||
)}
|
||||
|
||||
{blueArchivedRounds.length > 0 && (
|
||||
<div className="my-2 ml-6">
|
||||
{blueArchivedRounds.map((round) => (
|
||||
<ArchivedRoundCard key={round.id} round={round} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBlueQueue && (
|
||||
<PhaseRow
|
||||
dotClass="border-yellow-500/60"
|
||||
@@ -230,7 +301,7 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
||||
<PhaseRow
|
||||
dotClass="border-indigo-500/60"
|
||||
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
|
||||
label="Blue Evaluation"
|
||||
label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"}
|
||||
startTs={blue_work_started_at}
|
||||
duration={blueEvalSecs}
|
||||
isLast={++phaseIdx === lastIdx}
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface Test {
|
||||
paused_at: string | null;
|
||||
red_paused_seconds: number;
|
||||
blue_paused_seconds: number;
|
||||
red_round_number: number;
|
||||
blue_round_number: number;
|
||||
|
||||
// Remediation fields
|
||||
remediation_steps: string | null;
|
||||
@@ -165,6 +167,34 @@ export interface Test {
|
||||
// Separated evidences
|
||||
red_evidences: Evidence[];
|
||||
blue_evidences: Evidence[];
|
||||
|
||||
// Archived rounds — full history, oldest first
|
||||
round_history: TestRoundHistory[];
|
||||
}
|
||||
|
||||
// ── Round history (archived on reopen) ──────────────────────────────
|
||||
|
||||
export interface TestRoundHistory {
|
||||
id: string;
|
||||
team: "red" | "blue";
|
||||
round_number: number;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
paused_seconds: number;
|
||||
procedure_text: string | null;
|
||||
tool_used: string | null;
|
||||
attack_success: AttackSuccessResult | null;
|
||||
red_summary: string | null;
|
||||
execution_start_time: string | null;
|
||||
execution_end_time: string | null;
|
||||
detection_result: TestResult | null;
|
||||
containment_result: ContainmentResult | null;
|
||||
detection_time: string | null;
|
||||
containment_time: string | null;
|
||||
blue_summary: string | null;
|
||||
review_notes: string | null;
|
||||
reviewed_by: string | null;
|
||||
archived_at: string | null;
|
||||
}
|
||||
|
||||
// ── Evidence (v2 — with team) ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user