feat(phase-26): add Campaign models, endpoints, service with kill chain timeline UI (T-217 to T-220)
This commit is contained in:
416
frontend/src/pages/CampaignDetailPage.tsx
Normal file
416
frontend/src/pages/CampaignDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
349
frontend/src/pages/CampaignsPage.tsx
Normal file
349
frontend/src/pages/CampaignsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user