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:
@@ -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