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

@@ -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 */}