feat(phase-36): automatic Tempo time tracking via workflow buttons + fix campaign test management

- Add red_started_at/blue_started_at timing fields to Test model with Alembic migration

- Modify workflow transitions to auto-create integrity-hashed worklogs: Start Execution records red_started_at, Submit to Blue Team stops Red timer and creates worklog then starts Blue timer, Submit for Review stops Blue timer and creates worklog

- Auto-sync worklogs to Tempo when test has a Jira link

- Add LiveTimer component showing real-time elapsed counter during active phases

- Clear timing fields on test reopen

- Fix campaign test management: replace broken navigate-to-tests flow with AddTestToCampaignModal that lets users search and add existing tests directly from the campaign detail page
This commit is contained in:
2026-02-17 16:59:19 +01:00
parent 005a09b42f
commit febf460580
10 changed files with 461 additions and 5 deletions

View File

@@ -0,0 +1,197 @@
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
X,
Search,
Plus,
Loader2,
CheckCircle,
FlaskConical,
} from "lucide-react";
import { getTests } from "../api/tests";
import { addTestToCampaign } from "../api/campaigns";
import type { Test, TestState } from "../types/models";
const stateBadge: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
};
interface AddTestToCampaignModalProps {
campaignId: string;
existingTestIds: string[];
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function AddTestToCampaignModal({
campaignId,
existingTestIds,
open,
onClose,
onSuccess,
}: AddTestToCampaignModalProps) {
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState("");
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
const { data: allTests, isLoading } = useQuery({
queryKey: ["tests", "for-campaign-picker"],
queryFn: () => getTests({ limit: 200 }),
enabled: open,
});
const filteredTests = useMemo(() => {
if (!allTests) return [];
const alreadyIn = new Set([...existingTestIds, ...addedIds]);
let results = allTests.filter((t) => !alreadyIn.has(t.id));
if (searchText.trim()) {
const q = searchText.toLowerCase();
results = results.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) ||
(t.technique_name && t.technique_name.toLowerCase().includes(q))
);
}
return results;
}, [allTests, searchText, existingTestIds, addedIds]);
const addMutation = useMutation({
mutationFn: (testId: string) =>
addTestToCampaign(campaignId, { test_id: testId }),
onSuccess: (_data, testId) => {
setAddedIds((prev) => new Set(prev).add(testId));
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
onSuccess();
},
});
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<h2 className="text-lg font-semibold text-white">
Add Tests to Campaign
</h2>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Search */}
<div className="border-b border-gray-800 px-6 py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search tests by name or technique..."
autoFocus
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2.5 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
{/* Test list */}
<div className="max-h-[400px] overflow-y-auto px-6 py-3">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : filteredTests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<FlaskConical className="mb-2 h-8 w-8 text-gray-600" />
<p className="text-sm">
{searchText
? "No tests match your search."
: "All available tests are already in this campaign."}
</p>
</div>
) : (
<div className="space-y-1">
{filteredTests.map((test: Test) => (
<div
key={test.id}
className="flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3 hover:border-gray-700 hover:bg-gray-800/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-200 truncate">
{test.name}
</span>
<span
className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${
stateBadge[test.state]
}`}
>
{test.state.replace(/_/g, " ")}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
{test.technique_mitre_id && (
<span className="font-mono text-cyan-400/70">
{test.technique_mitre_id}
</span>
)}
{test.technique_name && (
<span className="truncate">{test.technique_name}</span>
)}
{test.platform && (
<span className="capitalize">{test.platform}</span>
)}
</div>
</div>
<button
onClick={() => addMutation.mutate(test.id)}
disabled={addMutation.isPending && addMutation.variables === test.id}
className="ml-3 flex shrink-0 items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 transition-colors"
>
{addMutation.isPending && addMutation.variables === test.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
<div className="text-xs text-gray-500">
{addedIds.size > 0 && (
<span className="flex items-center gap-1 text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
{addedIds.size} test{addedIds.size !== 1 ? "s" : ""} added
</span>
)}
</div>
<button
onClick={onClose}
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { Timer } from "lucide-react";
interface LiveTimerProps {
startedAt: string;
label: string;
variant: "red" | "blue";
}
/**
* Real-time elapsed timer that counts up from a given start timestamp.
* Shown while a Red/Blue Team phase is active so users can see
* exactly how long they've been working. This time is recorded
* as an automatic worklog when the phase ends.
*/
export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const start = new Date(startedAt).getTime();
const tick = () => {
const now = Date.now();
setElapsed(Math.max(0, Math.floor((now - start) / 1000)));
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [startedAt]);
const hours = Math.floor(elapsed / 3600);
const minutes = Math.floor((elapsed % 3600) / 60);
const seconds = elapsed % 60;
const pad = (n: number) => String(n).padStart(2, "0");
const colors =
variant === "red"
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400";
return (
<div
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}
>
<div className="relative flex items-center">
<Timer className="h-4 w-4" />
<span
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} animate-pulse`}
/>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
{label}
</span>
<span className="font-mono text-sm font-bold tabular-nums">
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
</span>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
ShieldCheck,
} from "lucide-react";
import type { Test, TestState, User } from "../../types/models";
import LiveTimer from "./LiveTimer";
// ── Progress steps ─────────────────────────────────────────────────
@@ -235,6 +236,30 @@ export default function TestDetailHeader({
);
};
// ── Live timer ───────────────────────────────────────────────────
const renderLiveTimer = () => {
if (test.state === "red_executing" && test.red_started_at) {
return (
<LiveTimer
startedAt={test.red_started_at}
label="Red Team Timer"
variant="red"
/>
);
}
if (test.state === "blue_evaluating" && test.blue_started_at) {
return (
<LiveTimer
startedAt={test.blue_started_at}
label="Blue Team Timer"
variant="blue"
/>
);
}
return null;
};
// ── Render ───────────────────────────────────────────────────────
return (
@@ -263,7 +288,10 @@ export default function TestDetailHeader({
</div>
</div>
{renderActions()}
<div className="flex flex-col items-end gap-2">
{renderLiveTimer()}
{renderActions()}
</div>
</div>
{/* Progress bar */}

View File

@@ -30,6 +30,7 @@ import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -61,6 +62,7 @@ export default function CampaignDetailPage() {
const { user } = useAuth();
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showAddTestModal, setShowAddTestModal] = useState(false);
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
@@ -500,7 +502,7 @@ export default function CampaignDetailPage() {
</h2>
{canManage && campaign.status === "draft" && (
<button
onClick={() => navigate(`/tests?campaign=${campaignId}`)}
onClick={() => setShowAddTestModal(true)}
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<Plus className="h-4 w-4" />
@@ -606,6 +608,15 @@ export default function CampaignDetailPage() {
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
</div>
{/* Add Test to Campaign Modal */}
<AddTestToCampaignModal
campaignId={campaignId!}
existingTestIds={campaign.tests.map((ct) => ct.test_id)}
open={showAddTestModal}
onClose={() => setShowAddTestModal(false)}
onSuccess={() => showToast("Test added to campaign", "success")}
/>
{/* Toast notification */}
{toast && (
<div

View File

@@ -86,6 +86,10 @@ export interface Test {
blue_validation_status: ValidationStatus | null;
blue_validation_notes: string | null;
// Phase timing fields (for automatic Tempo worklogs)
red_started_at: string | null;
blue_started_at: string | null;
// Remediation fields
remediation_steps: string | null;
remediation_status: string | null;