feat(campaigns): campaign timing panel with Red/Blue aggregated metrics
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend: GET /campaigns/{id}/timing-summary
  Aggregates timing across all campaign tests:
  - red_execution_secs: red_started_at → blue_started_at (minus paused)
  - blue_queue_secs:    blue_started_at → blue_work_started_at
  - blue_evaluation_secs: blue_work_started_at → validated (minus paused)
  - total_secs: sum of all three phases
  Returns totals + per-test breakdown sorted by total time desc.

Frontend: new CampaignTimingPanel component replaces WorklogTimeline
  - 4 summary cards: Red Execution / Blue Queue / Blue Evaluation / Total
  - Stacked horizontal bar showing time distribution
  - Per-test breakdown with individual mini-bars and phase durations
  - Shows 'No tests started yet' when no timing data available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-02 11:06:42 +02:00
parent a518c06653
commit 2b41b191bd
4 changed files with 318 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ test ordering, progress tracking, and threat actor integration.
import logging import logging
import uuid import uuid
from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -15,6 +16,8 @@ from pydantic import BaseModel, Field
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role from app.dependencies.auth import get_current_user, require_any_role
from app.models.user import User from app.models.user import User
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test
from app.services.campaign_service import generate_campaign_from_threat_actor from app.services.campaign_service import generate_campaign_from_threat_actor
from app.services.campaign_crud_service import ( from app.services.campaign_crud_service import (
add_test_to_campaign as crud_add_test, add_test_to_campaign as crud_add_test,
@@ -454,3 +457,97 @@ def get_campaign_history(
): ):
"""List all child campaigns (execution history) of a recurring campaign.""" """List all child campaigns (execution history) of a recurring campaign."""
return crud_get_history(db, campaign_id) return crud_get_history(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
# ---------------------------------------------------------------------------
def _seconds_between(start: datetime | None, end: datetime | None) -> int:
"""Return elapsed seconds between two datetimes; 0 if either is None."""
if not start or not end:
return 0
diff = (end - start).total_seconds()
return max(0, int(diff))
@router.get("/{campaign_id}/timing-summary")
def get_campaign_timing_summary(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return aggregated Red/Blue timing metrics for all tests in a campaign.
For each test we calculate:
- red_execution_secs : red_started_at → blue_started_at (minus red_paused_seconds)
- blue_queue_secs : blue_started_at → blue_work_started_at (waiting for Blue pick-up)
- blue_evaluation_secs: blue_work_started_at → first validation timestamp (minus blue_paused_seconds)
- total_secs : sum of the three phases
Returns totals + per-test breakdown.
"""
# Load campaign
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Campaign not found")
# Load all tests for this campaign
test_ids = [
ct.test_id
for ct in db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
]
tests = db.query(Test).filter(Test.id.in_(test_ids)).all() if test_ids else []
breakdown = []
total_red = 0
total_queue = 0
total_blue = 0
for t in tests:
# Red execution: from start-execution to submit-to-blue, minus paused time
red_secs = max(
0,
_seconds_between(t.red_started_at, t.blue_started_at) - (t.red_paused_seconds or 0),
)
# Blue queue: from receiving the test to actually starting evaluation
queue_secs = _seconds_between(t.blue_started_at, t.blue_work_started_at)
# Blue evaluation: from starting evaluation to first validation, minus paused time
eval_end = t.red_validated_at or t.blue_validated_at
blue_secs = max(
0,
_seconds_between(t.blue_work_started_at, eval_end) - (t.blue_paused_seconds or 0),
)
total_red += red_secs
total_queue += queue_secs
total_blue += blue_secs
breakdown.append({
"test_id": str(t.id),
"test_name": t.name,
"state": t.state.value if t.state else None,
"red_execution_secs": red_secs,
"blue_queue_secs": queue_secs,
"blue_evaluation_secs": blue_secs,
"total_secs": red_secs + queue_secs + blue_secs,
"has_timing": bool(t.red_started_at),
})
total_secs = total_red + total_queue + total_blue
return {
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"tests_total": len(tests),
"tests_with_timing": sum(1 for b in breakdown if b["has_timing"]),
"red_execution_secs": total_red,
"blue_queue_secs": total_queue,
"blue_evaluation_secs": total_blue,
"total_secs": total_secs,
"breakdown": sorted(breakdown, key=lambda x: -(x["total_secs"])),
}

View File

@@ -194,6 +194,37 @@ export async function deleteCampaign(
}); });
} }
// ── Timing summary ─────────────────────────────────────────────────
export interface CampaignTimingBreakdown {
test_id: string;
test_name: string;
state: string | null;
red_execution_secs: number;
blue_queue_secs: number;
blue_evaluation_secs: number;
total_secs: number;
has_timing: boolean;
}
export interface CampaignTimingSummary {
campaign_id: string;
campaign_name: string;
tests_total: number;
tests_with_timing: number;
red_execution_secs: number;
blue_queue_secs: number;
blue_evaluation_secs: number;
total_secs: number;
breakdown: CampaignTimingBreakdown[];
}
/** Get aggregated Red/Blue timing metrics for a campaign. */
export async function getCampaignTiming(campaignId: string): Promise<CampaignTimingSummary> {
const { data } = await client.get<CampaignTimingSummary>(`/campaigns/${campaignId}/timing-summary`);
return data;
}
/** Get execution history (child campaigns) for a recurring campaign. */ /** Get execution history (child campaigns) for a recurring campaign. */
export async function getCampaignHistory(campaignId: string): Promise<{ export async function getCampaignHistory(campaignId: string): Promise<{
campaign_id: string; campaign_id: string;

View File

@@ -0,0 +1,187 @@
/**
* CampaignTimingPanel
*
* Shows aggregated Red/Blue timing metrics for all tests in a campaign:
* - Red Team execution time (running the attacks)
* - Blue Team queue time (waiting before picking up)
* - Blue Team evaluation time (analyzing and validating)
* - Total campaign test time
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, Timer, Swords, Shield, Clock } from "lucide-react";
import { getCampaignTiming, type CampaignTimingBreakdown } from "../api/campaigns";
/* ── helpers ─────────────────────────────────────────────────────── */
function fmtDuration(secs: number): string {
if (secs <= 0) return "—";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function BarSegment({
value,
total,
color,
label,
}: {
value: number;
total: number;
color: string;
label: string;
}) {
if (!total || !value) return null;
const pct = Math.max(2, (value / total) * 100);
return (
<div
className={`h-full rounded-sm ${color} transition-all`}
style={{ width: `${pct}%` }}
title={`${label}: ${fmtDuration(value)}`}
/>
);
}
/* ── component ───────────────────────────────────────────────────── */
interface Props {
campaignId: string;
}
export default function CampaignTimingPanel({ campaignId }: Props) {
const { data, isLoading } = useQuery({
queryKey: ["campaign-timing", campaignId],
queryFn: () => getCampaignTiming(campaignId),
staleTime: 60_000,
});
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-1 flex items-center gap-2 text-lg font-semibold text-white">
<Timer className="h-5 w-5 text-cyan-400" />
Campaign Timing
</h2>
<p className="mb-4 text-xs text-gray-500">
Aggregated Red/Blue time across all tests
</p>
{isLoading && (
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
)}
{data && (
<>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mb-5">
{[
{
label: "Red Execution",
value: data.red_execution_secs,
icon: <Swords className="h-4 w-4 text-orange-400" />,
color: "text-orange-400",
bg: "bg-orange-500/10 border-orange-500/20",
},
{
label: "Blue Queue",
value: data.blue_queue_secs,
icon: <Clock className="h-4 w-4 text-yellow-400" />,
color: "text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
},
{
label: "Blue Evaluation",
value: data.blue_evaluation_secs,
icon: <Shield className="h-4 w-4 text-indigo-400" />,
color: "text-indigo-400",
bg: "bg-indigo-500/10 border-indigo-500/20",
},
{
label: "Total",
value: data.total_secs,
icon: <Timer className="h-4 w-4 text-cyan-400" />,
color: "text-cyan-400",
bg: "bg-cyan-500/10 border-cyan-500/20",
},
].map(({ label, value, icon, color, bg }) => (
<div key={label} className={`rounded-lg border p-3 ${bg}`}>
<div className="flex items-center gap-2 mb-1">
{icon}
<span className="text-[10px] font-medium uppercase tracking-wider text-gray-500">
{label}
</span>
</div>
<p className={`text-lg font-bold tabular-nums ${color}`}>
{fmtDuration(value)}
</p>
</div>
))}
</div>
{/* Stacked bar */}
{data.total_secs > 0 && (
<div className="mb-5">
<div className="flex h-3 w-full gap-0.5 overflow-hidden rounded-full bg-gray-800">
<BarSegment value={data.red_execution_secs} total={data.total_secs} color="bg-orange-500" label="Red Execution" />
<BarSegment value={data.blue_queue_secs} total={data.total_secs} color="bg-yellow-500" label="Blue Queue" />
<BarSegment value={data.blue_evaluation_secs} total={data.total_secs} color="bg-indigo-500" label="Blue Evaluation" />
</div>
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-gray-500">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" />Red Execution</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-yellow-500" />Blue Queue</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-indigo-500" />Blue Evaluation</span>
</div>
</div>
)}
{/* Per-test breakdown */}
{data.breakdown.filter((b) => b.has_timing).length > 0 && (
<div>
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Per test ({data.tests_with_timing} of {data.tests_total} with timing data)
</p>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{data.breakdown
.filter((b: CampaignTimingBreakdown) => b.has_timing)
.map((b: CampaignTimingBreakdown) => (
<div key={b.test_id} className="rounded-lg border border-gray-800 bg-gray-800/30 px-3 py-2">
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-medium text-gray-200 truncate max-w-[60%]">
{b.test_name}
</p>
<span className="text-xs font-mono text-cyan-400 tabular-nums shrink-0">
{fmtDuration(b.total_secs)}
</span>
</div>
{/* Mini stacked bar */}
<div className="flex h-1.5 w-full gap-0.5 overflow-hidden rounded-full bg-gray-700">
<BarSegment value={b.red_execution_secs} total={b.total_secs} color="bg-orange-500" label="Red" />
<BarSegment value={b.blue_queue_secs} total={b.total_secs} color="bg-yellow-500" label="Queue" />
<BarSegment value={b.blue_evaluation_secs} total={b.total_secs} color="bg-indigo-500" label="Blue" />
</div>
<div className="mt-1 flex gap-3 text-[9px] text-gray-500 tabular-nums">
{b.red_execution_secs > 0 && <span>🗡 {fmtDuration(b.red_execution_secs)}</span>}
{b.blue_queue_secs > 0 && <span> {fmtDuration(b.blue_queue_secs)}</span>}
{b.blue_evaluation_secs > 0 && <span>🛡 {fmtDuration(b.blue_evaluation_secs)}</span>}
</div>
</div>
))}
</div>
</div>
)}
{data.tests_with_timing === 0 && (
<p className="text-center text-sm text-gray-500 py-4">
No tests have started execution yet.
</p>
)}
</>
)}
</div>
);
}

View File

@@ -31,7 +31,7 @@ import {
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline"; import CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel"; import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline"; import CampaignTimingPanel from "../components/CampaignTimingPanel";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal"; import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
@@ -629,10 +629,10 @@ export default function CampaignDetailPage() {
)} )}
</div> </div>
{/* Jira & Worklogs — read-only, automatically managed */} {/* Jira + Campaign Timing */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} /> <JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
<WorklogTimeline entityType="campaign" entityId={campaignId!} readOnly /> <CampaignTimingPanel campaignId={campaignId!} />
</div> </div>
{/* Add Test to Campaign Modal */} {/* Add Test to Campaign Modal */}