diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 679e12b..0549cd7 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -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"])), + } diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index d9ce326..0c63ab0 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -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 { + const { data } = await client.get(`/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; diff --git a/frontend/src/components/CampaignTimingPanel.tsx b/frontend/src/components/CampaignTimingPanel.tsx new file mode 100644 index 0000000..799a939 --- /dev/null +++ b/frontend/src/components/CampaignTimingPanel.tsx @@ -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 ( +
+ ); +} + +/* ── 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 ( +
+

+ + Campaign Timing +

+

+ Aggregated Red/Blue time across all tests +

+ + {isLoading && ( +
+ +
+ )} + + {data && ( + <> + {/* Summary cards */} +
+ {[ + { + label: "Red Execution", + value: data.red_execution_secs, + icon: , + color: "text-orange-400", + bg: "bg-orange-500/10 border-orange-500/20", + }, + { + label: "Blue Queue", + value: data.blue_queue_secs, + icon: , + color: "text-yellow-400", + bg: "bg-yellow-500/10 border-yellow-500/20", + }, + { + label: "Blue Evaluation", + value: data.blue_evaluation_secs, + icon: , + color: "text-indigo-400", + bg: "bg-indigo-500/10 border-indigo-500/20", + }, + { + label: "Total", + value: data.total_secs, + icon: , + color: "text-cyan-400", + bg: "bg-cyan-500/10 border-cyan-500/20", + }, + ].map(({ label, value, icon, color, bg }) => ( +
+
+ {icon} + + {label} + +
+

+ {fmtDuration(value)} +

+
+ ))} +
+ + {/* Stacked bar */} + {data.total_secs > 0 && ( +
+
+ + + +
+
+ Red Execution + Blue Queue + Blue Evaluation +
+
+ )} + + {/* Per-test breakdown */} + {data.breakdown.filter((b) => b.has_timing).length > 0 && ( +
+

+ Per test ({data.tests_with_timing} of {data.tests_total} with timing data) +

+
+ {data.breakdown + .filter((b: CampaignTimingBreakdown) => b.has_timing) + .map((b: CampaignTimingBreakdown) => ( +
+
+

+ {b.test_name} +

+ + {fmtDuration(b.total_secs)} + +
+ {/* Mini stacked bar */} +
+ + + +
+
+ {b.red_execution_secs > 0 && 🗡 {fmtDuration(b.red_execution_secs)}} + {b.blue_queue_secs > 0 && ⏳ {fmtDuration(b.blue_queue_secs)}} + {b.blue_evaluation_secs > 0 && 🛡 {fmtDuration(b.blue_evaluation_secs)}} +
+
+ ))} +
+
+ )} + + {data.tests_with_timing === 0 && ( +

+ No tests have started execution yet. +

+ )} + + )} +
+ ); +} diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index 6beb018..f63fa31 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -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 = { @@ -629,10 +629,10 @@ export default function CampaignDetailPage() { )}
- {/* Jira & Worklogs — read-only, automatically managed */} + {/* Jira + Campaign Timing */}
- +
{/* Add Test to Campaign Modal */}