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

@@ -14,6 +14,8 @@ import AuditLogPage from "./pages/AuditLogPage";
import DataSourcesPage from "./pages/DataSourcesPage";
import ThreatActorsPage from "./pages/ThreatActorsPage";
import ThreatActorDetailPage from "./pages/ThreatActorDetailPage";
import CampaignsPage from "./pages/CampaignsPage";
import CampaignDetailPage from "./pages/CampaignDetailPage";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
@@ -42,6 +44,8 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} />
<Route path="/threat-actors" element={<ThreatActorsPage />} />
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route
path="/system"
element={

View File

@@ -0,0 +1,153 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export interface CampaignTest {
id: string;
test_id: string;
order_index: number;
depends_on: string | null;
phase: string | null;
test_name: string | null;
test_state: string | null;
test_result: string | null;
technique_mitre_id: string | null;
technique_name: string | null;
platform: string | null;
}
export interface CampaignProgress {
total: number;
by_state: Record<string, number>;
completion_pct: number;
}
export interface Campaign {
id: string;
name: string;
description: string | null;
type: string;
status: string;
threat_actor_id: string | null;
threat_actor_name: string | null;
created_by: string | null;
scheduled_at: string | null;
completed_at: string | null;
target_platform: string | null;
tags: string[];
created_at: string | null;
tests: CampaignTest[];
progress: CampaignProgress;
}
export interface CampaignSummary {
id: string;
name: string;
description: string | null;
type: string;
status: string;
threat_actor_id: string | null;
threat_actor_name: string | null;
target_platform: string | null;
tags: string[];
created_at: string | null;
test_count: number;
completion_pct: number;
}
export interface CampaignCreatePayload {
name: string;
description?: string;
type?: string;
threat_actor_id?: string;
target_platform?: string;
tags?: string[];
scheduled_at?: string;
}
export interface AddTestPayload {
test_id: string;
order_index?: number;
depends_on?: string;
phase?: string;
}
// ── API Functions ───────────────────────────────────────────────────
/** List campaigns with optional filters. */
export async function listCampaigns(params?: {
type?: string;
status?: string;
threat_actor_id?: string;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ total: number; items: CampaignSummary[] }> {
const { data } = await client.get("/campaigns", { params });
return data;
}
/** Get a campaign detail with tests and progress. */
export async function getCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.get<Campaign>(`/campaigns/${campaignId}`);
return data;
}
/** Create a new campaign. */
export async function createCampaign(payload: CampaignCreatePayload): Promise<Campaign> {
const { data } = await client.post<Campaign>("/campaigns", payload);
return data;
}
/** Update a campaign. */
export async function updateCampaign(
campaignId: string,
payload: Partial<CampaignCreatePayload>,
): Promise<Campaign> {
const { data } = await client.patch<Campaign>(`/campaigns/${campaignId}`, payload);
return data;
}
/** Add a test to a campaign. */
export async function addTestToCampaign(
campaignId: string,
payload: AddTestPayload,
): Promise<CampaignTest> {
const { data } = await client.post<CampaignTest>(`/campaigns/${campaignId}/tests`, payload);
return data;
}
/** Remove a test from a campaign. */
export async function removeTestFromCampaign(
campaignId: string,
campaignTestId: string,
): Promise<void> {
await client.delete(`/campaigns/${campaignId}/tests/${campaignTestId}`);
}
/** Activate a campaign. */
export async function activateCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate`);
return data;
}
/** Mark a campaign as completed. */
export async function completeCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/complete`);
return data;
}
/** Get campaign progress. */
export async function getCampaignProgress(campaignId: string): Promise<CampaignProgress & {
campaign_id: string;
campaign_name: string;
}> {
const { data } = await client.get(`/campaigns/${campaignId}/progress`);
return data;
}
/** Generate a campaign from a threat actor. */
export async function generateCampaignFromThreatActor(actorId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`);
return data;
}

View File

@@ -0,0 +1,161 @@
import { useNavigate } from "react-router-dom";
import type { CampaignTest } from "../api/campaigns";
// Kill chain phases in display order
const PHASES = [
{ key: "reconnaissance", label: "Reconnaissance", color: "border-gray-500 bg-gray-500" },
{ key: "resource_development", label: "Resource Dev", color: "border-gray-400 bg-gray-400" },
{ key: "initial_access", label: "Initial Access", color: "border-red-500 bg-red-500" },
{ key: "execution", label: "Execution", color: "border-orange-500 bg-orange-500" },
{ key: "persistence", label: "Persistence", color: "border-amber-500 bg-amber-500" },
{ key: "privilege_escalation", label: "Priv Escalation", color: "border-yellow-500 bg-yellow-500" },
{ key: "defense_evasion", label: "Defense Evasion", color: "border-lime-500 bg-lime-500" },
{ key: "credential_access", label: "Cred Access", color: "border-green-500 bg-green-500" },
{ key: "discovery", label: "Discovery", color: "border-emerald-500 bg-emerald-500" },
{ key: "lateral_movement", label: "Lateral Movement", color: "border-teal-500 bg-teal-500" },
{ key: "collection", label: "Collection", color: "border-cyan-500 bg-cyan-500" },
{ key: "command_and_control", label: "C2", color: "border-blue-500 bg-blue-500" },
{ key: "exfiltration", label: "Exfiltration", color: "border-indigo-500 bg-indigo-500" },
{ key: "impact", label: "Impact", color: "border-purple-500 bg-purple-500" },
];
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
rejected: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
};
interface Props {
tests: CampaignTest[];
}
export default function CampaignTimeline({ tests }: Props) {
const navigate = useNavigate();
// Group tests by phase
const testsByPhase: Record<string, CampaignTest[]> = {};
const unphased: CampaignTest[] = [];
for (const t of tests) {
if (t.phase) {
if (!testsByPhase[t.phase]) testsByPhase[t.phase] = [];
testsByPhase[t.phase].push(t);
} else {
unphased.push(t);
}
}
// Filter phases that have tests
const activePhases = PHASES.filter((p) => testsByPhase[p.key]?.length > 0);
if (tests.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-800/30 p-8 text-center">
<p className="text-sm text-gray-400">No tests in this campaign yet.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Horizontal timeline */}
<div className="overflow-x-auto pb-2">
<div className="flex gap-4 min-w-max px-2 py-4">
{activePhases.map((phase, phaseIdx) => {
const phaseTests = testsByPhase[phase.key] || [];
const phaseColor = phase.color.split(" ");
return (
<div key={phase.key} className="flex items-start gap-4">
{/* Phase column */}
<div className="flex flex-col items-center">
{/* Phase label */}
<div className={`mb-2 rounded-full border-2 ${phaseColor[0]} px-3 py-1`}>
<span className="text-[10px] font-semibold text-white whitespace-nowrap">
{phase.label}
</span>
</div>
{/* Phase connector line */}
<div className={`w-0.5 h-2 ${phaseColor[1]}`} />
{/* Tests in this phase */}
<div className="flex flex-col gap-2">
{phaseTests.map((ct) => {
const state = ct.test_state || "draft";
const colors = stateColors[state] || stateColors.draft;
return (
<button
key={ct.id}
onClick={() => navigate(`/tests/${ct.test_id}`)}
className={`group w-48 rounded-lg border ${colors.border} ${colors.bg} p-3 text-left transition-all hover:scale-105 hover:shadow-lg`}
>
<p className={`text-xs font-medium ${colors.text} truncate`}>
{ct.technique_mitre_id && (
<span className="font-mono mr-1">{ct.technique_mitre_id}</span>
)}
</p>
<p className="text-[11px] text-gray-300 truncate mt-0.5">
{ct.test_name || "Unnamed test"}
</p>
<div className="mt-1.5 flex items-center justify-between">
<span className={`text-[10px] font-medium ${colors.text} capitalize`}>
{state.replace(/_/g, " ")}
</span>
{ct.platform && (
<span className="text-[10px] text-gray-500 capitalize">{ct.platform}</span>
)}
</div>
</button>
);
})}
</div>
</div>
{/* Arrow between phases */}
{phaseIdx < activePhases.length - 1 && (
<div className="flex items-center self-center mt-8">
<div className="h-0.5 w-6 bg-gray-700" />
<div className="border-t-4 border-b-4 border-l-6 border-t-transparent border-b-transparent border-l-gray-700" />
</div>
)}
</div>
);
})}
</div>
</div>
{/* Unphased tests */}
{unphased.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase text-gray-500">Unassigned Phase</h4>
<div className="flex flex-wrap gap-2">
{unphased.map((ct) => {
const state = ct.test_state || "draft";
const colors = stateColors[state] || stateColors.draft;
return (
<button
key={ct.id}
onClick={() => navigate(`/tests/${ct.test_id}`)}
className={`rounded-lg border ${colors.border} ${colors.bg} px-3 py-2 text-left transition-all hover:scale-105`}
>
<p className="text-xs text-gray-300 truncate max-w-[200px]">
{ct.test_name || "Unnamed test"}
</p>
<span className={`text-[10px] font-medium ${colors.text} capitalize`}>
{state.replace(/_/g, " ")}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
ClipboardList,
Database,
Crosshair,
Zap,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -39,6 +40,7 @@ const mainLinks: NavItem[] = [
},
{ to: "/reports", label: "Reports", icon: BarChart3 },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/campaigns", label: "Campaigns", icon: Zap },
];
const adminLinks: NavItem[] = [

View File

@@ -0,0 +1,416 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
ArrowLeft,
Play,
CheckCircle,
Target,
Plus,
Trash2,
Zap,
Calendar,
Clock,
} from "lucide-react";
import {
getCampaign,
activateCampaign,
completeCampaign,
removeTestFromCampaign,
type Campaign,
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
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 typeLabels: Record<string, string> = {
custom: "Custom",
apt_emulation: "APT Emulation",
kill_chain: "Kill Chain",
compliance: "Compliance",
};
const testStateColors: Record<string, 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",
};
export default function CampaignDetailPage() {
const { campaignId } = useParams<{ campaignId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), 5000);
};
const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_tech";
const canComplete = role === "admin" || role === "red_lead";
const {
data: campaign,
isLoading,
error,
} = useQuery({
queryKey: ["campaign", campaignId],
queryFn: () => getCampaign(campaignId!),
enabled: !!campaignId,
});
const activateMutation = useMutation({
mutationFn: () => activateCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign activated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign completed", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const removeMutation = useMutation({
mutationFn: (campaignTestId: string) => removeTestFromCampaign(campaignId!, campaignTestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Test removed from campaign", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "\u2014";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error || !campaign) {
return (
<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 campaign</p>
<button
onClick={() => navigate("/campaigns")}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
</div>
);
}
const progress = campaign.progress;
return (
<div className="space-y-6">
{/* Back button */}
<button
onClick={() => navigate("/campaigns")}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
{/* Header */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-cyan-500/10 p-3">
<Zap className="h-8 w-8 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">{campaign.name}</h1>
<span
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium ${
statusColors[campaign.status] || statusColors.draft
}`}
>
{campaign.status}
</span>
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2.5 py-0.5 text-xs font-medium text-gray-400">
{typeLabels[campaign.type] || campaign.type}
</span>
</div>
{campaign.description && (
<p className="mt-1 text-sm text-gray-400">{campaign.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
{campaign.threat_actor_name && (
<button
onClick={() => navigate(`/threat-actors/${campaign.threat_actor_id}`)}
className="flex items-center gap-1 text-red-400 hover:underline"
>
<Target className="h-3.5 w-3.5" />
{campaign.threat_actor_name}
</button>
)}
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
Created {formatDate(campaign.created_at)}
</span>
{campaign.completed_at && (
<span className="flex items-center gap-1 text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
Completed {formatDate(campaign.completed_at)}
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{canManage && campaign.status === "draft" && (
<button
onClick={() => activateMutation.mutate()}
disabled={activateMutation.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"
>
{activateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Activate
</button>
)}
{canComplete && campaign.status === "active" && (
<button
onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50 transition-colors"
>
{completeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
Mark Completed
</button>
)}
</div>
</div>
</div>
{/* Progress Panel */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Progress</h2>
<div className="flex items-center gap-6 mb-4">
<div>
<span className="text-3xl font-bold text-white">{progress.completion_pct}%</span>
<span className="ml-1 text-sm text-gray-400">complete</span>
</div>
<div className="flex-1">
<div className="h-3 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
progress.completion_pct === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
style={{ width: `${progress.completion_pct}%` }}
/>
</div>
</div>
<span className="text-sm text-gray-400">
{progress.by_state?.validated || 0} / {progress.total} tests
</span>
</div>
{/* State breakdown */}
{progress.total > 0 && (
<div className="flex flex-wrap gap-3">
{Object.entries(progress.by_state || {}).map(([state, count]) => (
<div
key={state}
className={`rounded-lg border px-3 py-1.5 text-xs font-medium ${
testStateColors[state] || testStateColors.draft
}`}
>
{state.replace(/_/g, " ")}: {count}
</div>
))}
</div>
)}
</div>
{/* Kill Chain Timeline */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Kill Chain Timeline</h2>
{campaign.tags && campaign.tags.length > 0 && (
<div className="flex gap-1">
{campaign.tags.map((tag, i) => (
<span
key={i}
className="rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[10px] text-gray-400"
>
{tag}
</span>
))}
</div>
)}
</div>
<CampaignTimeline tests={campaign.tests} />
</div>
{/* Tests Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
Tests ({campaign.tests.length})
</h2>
{canManage && campaign.status === "draft" && (
<button
onClick={() => navigate(`/tests?campaign=${campaignId}`)}
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" />
Add Test
</button>
)}
</div>
{campaign.tests.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">#</th>
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Test Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Phase</th>
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{campaign.tests
.sort((a, b) => a.order_index - b.order_index)
.map((ct) => (
<tr
key={ct.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="py-3 pr-4">
<span className="text-xs text-gray-500">{ct.order_index + 1}</span>
</td>
<td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400">
{ct.technique_mitre_id || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="text-sm font-medium text-gray-200 hover:text-cyan-400 transition-colors"
>
{ct.test_name || "Unnamed test"}
</button>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.phase?.replace(/_/g, " ") || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
testStateColors[ct.test_state || "draft"] || testStateColors.draft
}`}
>
{(ct.test_state || "draft").replace(/_/g, " ")}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.platform || "\u2014"}
</span>
</td>
<td className="py-3 pl-4">
<div className="flex items-center gap-2">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-cyan-400"
title="View Test"
>
<Clock className="h-4 w-4" />
</button>
{canManage && (campaign.status === "draft" || campaign.status === "active") && (
<button
onClick={() => removeMutation.mutate(ct.id)}
disabled={removeMutation.isPending}
className="rounded p-1 text-gray-400 hover:bg-red-900/50 hover:text-red-400"
title="Remove from campaign"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<Zap className="mb-2 h-8 w-8 text-gray-600" />
<p>No tests in this campaign yet.</p>
</div>
)}
</div>
{/* Toast notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur transition-all ${
toast.type === "success"
? "border-green-500/30 bg-green-900/90 text-green-300"
: "border-red-500/30 bg-red-900/90 text-red-300"
}`}
>
{toast.message}
</div>
)}
</div>
);
}

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>
);
}