feat(phase-37): timer pause/resume + professional reporting engine
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Pause/Resume timer:
- Add paused_at, red_paused_seconds, blue_paused_seconds fields to Test model
- Add pause_timer/resume_timer workflow functions with accumulated pause tracking
- Auto-resume on phase submit; subtract paused time from worklog duration
- Add POST /tests/{id}/pause-timer and resume-timer endpoints
- Update LiveTimer component with pause/resume button and paused visual state
- Wire pause/resume mutations through TestDetailPage and TestDetailHeader

Professional Reporting Engine - Fase 2:
- Add ReportEngine service with Jinja2 HTML rendering, WeasyPrint PDF, and docxtpl DOCX
- Add corporate CSS stylesheet with cover page, data tables, stats grid, findings
- Create purple_campaign, coverage_report, and executive_summary HTML templates
- Add report_generation_service collecting domain data for each report type
- Add professional_reports router: GET /reports/generate/purple-campaign/{id}, coverage-summary, executive-summary
- Add analytics router with flat JSON endpoints for PowerBI: /coverage, /tests, /trends, /operators
- Add advanced_metrics router: /coverage-by-tactic, /never-tested, /avg-validation-time, /detection-rate-trend
- Add weasyprint and docxtpl to requirements.txt
- Add REPORT_TEMPLATES_DIR, REPORT_OUTPUT_DIR, COMPANY_NAME, COMPANY_LOGO_PATH to config
This commit is contained in:
2026-02-17 17:20:45 +01:00
parent febf460580
commit 31e116b4ba
23 changed files with 1564 additions and 25 deletions

View File

@@ -141,6 +141,20 @@ export async function submitRedEvidence(testId: string): Promise<Test> {
return data;
}
// ── Timer Controls ─────────────────────────────────────────────────
/** Pause the active phase timer. */
export async function pauseTimer(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/pause-timer`);
return data;
}
/** Resume a paused phase timer. */
export async function resumeTimer(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/resume-timer`);
return data;
}
// ── Blue Team ──────────────────────────────────────────────────────
/** Blue Team updates their fields (blue_evaluating only). */

View File

@@ -1,65 +1,108 @@
import { useState, useEffect } from "react";
import { Timer } from "lucide-react";
import { Timer, Pause, Play } from "lucide-react";
interface LiveTimerProps {
startedAt: string;
pausedAt: string | null;
pausedSeconds: number;
label: string;
variant: "red" | "blue";
onPause: () => void;
onResume: () => void;
canControl: boolean;
isToggling: boolean;
}
/**
* Real-time elapsed timer that counts up from a given start timestamp.
* Shown while a Red/Blue Team phase is active so users can see
* exactly how long they've been working. This time is recorded
* as an automatic worklog when the phase ends.
* Real-time elapsed timer that counts up from a given start timestamp,
* subtracting accumulated pause time. Shows pause/resume controls.
*/
export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) {
export default function LiveTimer({
startedAt,
pausedAt,
pausedSeconds,
label,
variant,
onPause,
onResume,
canControl,
isToggling,
}: LiveTimerProps) {
const [elapsed, setElapsed] = useState(0);
const isPaused = pausedAt !== null;
useEffect(() => {
const start = new Date(startedAt).getTime();
const tick = () => {
const now = Date.now();
setElapsed(Math.max(0, Math.floor((now - start) / 1000)));
const grossSeconds = Math.floor((now - start) / 1000);
let totalPaused = pausedSeconds;
if (isPaused) {
const pauseStart = new Date(pausedAt!).getTime();
totalPaused += Math.floor((now - pauseStart) / 1000);
}
setElapsed(Math.max(0, grossSeconds - totalPaused));
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [startedAt]);
if (!isPaused) {
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}
}, [startedAt, pausedAt, pausedSeconds, isPaused]);
const hours = Math.floor(elapsed / 3600);
const minutes = Math.floor((elapsed % 3600) / 60);
const seconds = elapsed % 60;
const pad = (n: number) => String(n).padStart(2, "0");
const colors =
variant === "red"
const colors = isPaused
? "border-yellow-500/40 bg-yellow-900/30 text-yellow-300"
: variant === "red"
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400";
const dotColor = isPaused
? "bg-yellow-400"
: variant === "red"
? "bg-orange-400"
: "bg-indigo-400";
return (
<div
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}
>
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}>
<div className="relative flex items-center">
<Timer className="h-4 w-4" />
<span
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} animate-pulse`}
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} ${
isPaused ? "" : "animate-pulse"
}`}
/>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
{label}
{label}{isPaused ? " (Paused)" : ""}
</span>
<span className="font-mono text-sm font-bold tabular-nums">
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
</span>
</div>
{canControl && (
<button
onClick={isPaused ? onResume : onPause}
disabled={isToggling}
className={`ml-1 rounded-md p-1.5 transition-colors disabled:opacity-50 ${
isPaused
? "bg-green-600/20 text-green-400 hover:bg-green-600/30"
: "bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30"
}`}
title={isPaused ? "Resume timer" : "Pause timer"}
>
{isPaused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
</button>
)}
</div>
);
}

View File

@@ -53,6 +53,9 @@ interface TestDetailHeaderProps {
onSubmitBlue: () => void;
onOpenValidateModal: (side: "red" | "blue") => void;
onReopen: () => void;
onPauseTimer: () => void;
onResumeTimer: () => void;
isTogglingTimer: boolean;
}
// ── Component ──────────────────────────────────────────────────────
@@ -66,6 +69,9 @@ export default function TestDetailHeader({
onSubmitBlue,
onOpenValidateModal,
onReopen,
onPauseTimer,
onResumeTimer,
isTogglingTimer,
}: TestDetailHeaderProps) {
const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state];
@@ -238,13 +244,23 @@ export default function TestDetailHeader({
// ── Live timer ───────────────────────────────────────────────────
const canControlTimer =
(test.state === "red_executing" && (role === "red_tech" || role === "admin")) ||
(test.state === "blue_evaluating" && (role === "blue_tech" || 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}
/>
);
}
@@ -252,8 +268,14 @@ export default function TestDetailHeader({
return (
<LiveTimer
startedAt={test.blue_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}
/>
);
}

View File

@@ -13,6 +13,8 @@ import {
validateAsRedLead,
validateAsBlueLead,
reopenTest,
pauseTimer,
resumeTimer,
getTestTimeline,
getRetestChain,
} from "../api/tests";
@@ -223,6 +225,25 @@ export default function TestDetailPage() {
},
});
// Timer pause/resume
const pauseTimerMutation = useMutation({
mutationFn: () => pauseTimer(testId!),
onSuccess: () => {
invalidateAll();
showToast("Timer paused", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const resumeTimerMutation = useMutation({
mutationFn: () => resumeTimer(testId!),
onSuccess: () => {
invalidateAll();
showToast("Timer resumed", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
// Evidence upload
const uploadMutation = useMutation({
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
@@ -351,6 +372,9 @@ export default function TestDetailPage() {
onSubmitBlue={() => submitBlueMutation.mutate()}
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
onReopen={() => setConfirmReopen(true)}
onPauseTimer={() => pauseTimerMutation.mutate()}
onResumeTimer={() => resumeTimerMutation.mutate()}
isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending}
/>
{/* Content: Tabs + Sidebar */}

View File

@@ -89,6 +89,9 @@ export interface Test {
// Phase timing fields (for automatic Tempo worklogs)
red_started_at: string | null;
blue_started_at: string | null;
paused_at: string | null;
red_paused_seconds: number;
blue_paused_seconds: number;
// Remediation fields
remediation_steps: string | null;