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