feat(phase-26): add Campaign models, endpoints, service with kill chain timeline UI (T-217 to T-220)

This commit is contained in:
2026-02-09 16:52:52 +01:00
parent f4de12d8ab
commit 57b47c296d
12 changed files with 2032 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
Plus,
Search,
Crosshair,
Zap,
Filter,
Target,
} from "lucide-react";
import { listCampaigns, createCampaign, type CampaignSummary } from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
completed: "bg-green-900/50 text-green-400 border-green-500/30",
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
};
const typeColors: Record<string, string> = {
custom: "bg-gray-800/50 text-gray-400 border-gray-600/30",
apt_emulation: "bg-red-900/50 text-red-400 border-red-500/30",
kill_chain: "bg-orange-900/50 text-orange-400 border-orange-500/30",
compliance: "bg-blue-900/50 text-blue-400 border-blue-500/30",
};
const typeLabels: Record<string, string> = {
custom: "Custom",
apt_emulation: "APT Emulation",
kill_chain: "Kill Chain",
compliance: "Compliance",
};
export default function CampaignsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const [filters, setFilters] = useState({
type: "",
status: "",
search: "",
});
const [showCreateForm, setShowCreateForm] = useState(false);
const [newCampaign, setNewCampaign] = useState({
name: "",
description: "",
type: "custom",
target_platform: "",
});
const canCreate = user?.role === "admin" || user?.role === "red_tech";
const { data, isLoading, error } = useQuery({
queryKey: ["campaigns", filters],
queryFn: () =>
listCampaigns({
type: filters.type || undefined,
status: filters.status || undefined,
search: filters.search || undefined,
}),
});
const createMutation = useMutation({
mutationFn: () => createCampaign(newCampaign),
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowCreateForm(false);
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
navigate(`/campaigns/${campaign.id}`);
},
});
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Campaigns</h1>
<p className="mt-1 text-sm text-gray-400">
Manage attack chain campaigns and APT emulations
</p>
</div>
<div className="flex items-center gap-3">
{canCreate && (
<>
<button
onClick={() => navigate("/threat-actors")}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors"
>
<Crosshair className="h-4 w-4" />
Generate from Threat Actor
</button>
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
New Campaign
</button>
</>
)}
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
placeholder="Search campaigns..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<select
value={filters.type}
onChange={(e) => setFilters((f) => ({ ...f, type: e.target.value }))}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Types</option>
<option value="custom">Custom</option>
<option value="apt_emulation">APT Emulation</option>
<option value="kill_chain">Kill Chain</option>
<option value="compliance">Compliance</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value }))}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Create Form Modal */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Create Campaign</h2>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Name</label>
<input
value={newCampaign.name}
onChange={(e) => setNewCampaign((c) => ({ ...c, name: e.target.value }))}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
placeholder="Campaign name..."
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
<textarea
value={newCampaign.description}
onChange={(e) => setNewCampaign((c) => ({ ...c, description: e.target.value }))}
rows={3}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
placeholder="Describe the campaign objective..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Type</label>
<select
value={newCampaign.type}
onChange={(e) => setNewCampaign((c) => ({ ...c, type: e.target.value }))}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="custom">Custom</option>
<option value="kill_chain">Kill Chain</option>
<option value="compliance">Compliance</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
<select
value={newCampaign.target_platform}
onChange={(e) => setNewCampaign((c) => ({ ...c, target_platform: e.target.value }))}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="">Any</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
</select>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowCreateForm(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()}
disabled={!newCampaign.name || createMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Create Campaign
</button>
</div>
</div>
</div>
)}
{/* Campaign grid */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
) : error ? (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load campaigns</p>
</div>
) : data && data.items.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.items.map((campaign) => (
<button
key={campaign.id}
onClick={() => navigate(`/campaigns/${campaign.id}`)}
className="group rounded-xl border border-gray-800 bg-gray-900 p-5 text-left transition-all hover:border-gray-700 hover:shadow-lg"
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
typeColors[campaign.type] || typeColors.custom
}`}
>
{typeLabels[campaign.type] || campaign.type}
</span>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
statusColors[campaign.status] || statusColors.draft
}`}
>
{campaign.status}
</span>
</div>
<Zap className="h-4 w-4 text-gray-600 group-hover:text-cyan-400 transition-colors" />
</div>
{/* Name & Description */}
<h3 className="text-sm font-semibold text-white group-hover:text-cyan-300 transition-colors truncate">
{campaign.name}
</h3>
{campaign.description && (
<p className="mt-1 text-xs text-gray-400 line-clamp-2">
{campaign.description}
</p>
)}
{/* Threat Actor */}
{campaign.threat_actor_name && (
<div className="mt-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-red-400" />
<span className="text-xs text-red-400">{campaign.threat_actor_name}</span>
</div>
)}
{/* Progress bar */}
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500">
{campaign.test_count} test{campaign.test_count !== 1 ? "s" : ""}
</span>
<span className="text-[10px] font-medium text-gray-400">
{campaign.completion_pct}%
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
campaign.completion_pct === 100
? "bg-green-500"
: campaign.completion_pct > 0
? "bg-cyan-500"
: "bg-gray-700"
}`}
style={{ width: `${campaign.completion_pct}%` }}
/>
</div>
</div>
{/* Tags */}
{campaign.tags && campaign.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{campaign.tags.slice(0, 3).map((tag, i) => (
<span
key={i}
className="rounded-full bg-gray-800 border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-400"
>
{tag}
</span>
))}
{campaign.tags.length > 3 && (
<span className="text-[10px] text-gray-500">+{campaign.tags.length - 3}</span>
)}
</div>
)}
{/* Date */}
<p className="mt-2 text-[10px] text-gray-600">{formatDate(campaign.created_at)}</p>
</button>
))}
</div>
) : (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<Zap className="h-10 w-10 text-gray-600" />
<p className="text-gray-400">No campaigns found</p>
{canCreate && (
<button
onClick={() => setShowCreateForm(true)}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<Plus className="h-4 w-4" />
Create your first campaign
</button>
)}
</div>
)}
</div>
);
}