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:
197
frontend/src/components/AddTestToCampaignModal.tsx
Normal file
197
frontend/src/components/AddTestToCampaignModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user