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 uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
@@ -15,6 +16,8 @@ from pydantic import BaseModel, Field
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
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_crud_service import (
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."""
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. */
export async function getCampaignHistory(campaignId: string): Promise<{
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 CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
import CampaignTimingPanel from "../components/CampaignTimingPanel";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
const statusColors: Record<string, string> = {
@@ -629,10 +629,10 @@ export default function CampaignDetailPage() {
)}
</div>
{/* Jira & Worklogs — read-only, automatically managed */}
{/* Jira + Campaign Timing */}
<div className="grid gap-6 lg:grid-cols-2">
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
<WorklogTimeline entityType="campaign" entityId={campaignId!} readOnly />
<CampaignTimingPanel campaignId={campaignId!} />
</div>
{/* Add Test to Campaign Modal */}