feat(campaigns): campaign timing panel with Red/Blue aggregated metrics
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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"])),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
187
frontend/src/components/CampaignTimingPanel.tsx
Normal file
187
frontend/src/components/CampaignTimingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user