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

- 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:
kitos
2026-07-13 12:08:45 +02:00
parent 0bb34f6834
commit 8fe38dc661
12 changed files with 526 additions and 26 deletions
+76 -5
View File
@@ -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}
+30
View File
@@ -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) ──────────────────────────────────────