Files
Aegis/frontend/src/pages/ThreatActorsPage.tsx
kitos a518c06653
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
feat(threat-actors): hover tooltip on motivation badges
New MotivationBadge component with CSS tooltip showing:
- espionage: goal (intelligence theft), typical behavior, examples
- financial: goal (monetary), typical behavior, examples
- destruction: goal (disrupt/destroy infra), wiper/ICS attacks, examples
- hacktivism: goal (political/ideological), defacement/leaks, examples

Used in ThreatActorsPage (card list) and ThreatActorDetailPage (header).
2026-06-02 10:50:37 +02:00

232 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Loader2,
AlertCircle,
Search,
Users,
Shield,
ChevronLeft,
ChevronRight,
Globe,
Target,
Crosshair,
} from "lucide-react";
import {
getThreatActors,
type ThreatActorSummary,
type ListThreatActorsParams,
} from "../api/threat-actors";
/** Coverage colour based on percentage. */
function coverageColor(pct: number) {
if (pct >= 80) return "text-green-400";
if (pct >= 50) return "text-yellow-400";
if (pct >= 20) return "text-orange-400";
return "text-red-400";
}
function coverageBg(pct: number) {
if (pct >= 80) return "bg-green-500";
if (pct >= 50) return "bg-yellow-500";
if (pct >= 20) return "bg-orange-500";
return "bg-red-500";
}
/** Motivation badge colour. */
import MotivationBadge from "../components/MotivationBadge";
export default function ThreatActorsPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [motivation, setMotivation] = useState("");
const [page, setPage] = useState(0);
const limit = 24;
const params: ListThreatActorsParams = {
offset: page * limit,
limit,
...(search ? { search } : {}),
...(motivation ? { motivation } : {}),
};
const { data, isLoading, error } = useQuery({
queryKey: ["threat-actors", params],
queryFn: () => getThreatActors(params),
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="h-7 w-7 text-purple-400" />
Threat Actors
</h1>
<p className="mt-1 text-sm text-gray-400">
APT groups and threat actor profiles from MITRE ATT&CK with coverage analysis
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search actors, aliases..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
{/* Motivation filter */}
<select
value={motivation}
onChange={(e) => { setMotivation(e.target.value); setPage(0); }}
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 Motivations</option>
<option value="espionage">Espionage</option>
<option value="financial">Financial</option>
<option value="destruction">Destruction</option>
<option value="hacktivism">Hacktivism</option>
</select>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
)}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-900/20 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-400">
Failed to load threat actors: {(error as Error)?.message}
</p>
</div>
)}
{/* Grid */}
{data && data.items.length > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.items.map((actor: ThreatActorSummary) => (
<button
key={actor.id}
onClick={() => navigate(`/threat-actors/${actor.id}`)}
className="group rounded-xl border border-gray-800 bg-gray-900 p-5 text-left transition-all hover:border-purple-500/40 hover:bg-gray-900/80"
>
{/* Name + ID */}
<div className="flex items-start justify-between">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-gray-200 group-hover:text-white">
{actor.name}
</h3>
{actor.mitre_id && (
<span className="text-xs font-mono text-purple-400">{actor.mitre_id}</span>
)}
</div>
<Crosshair className="h-5 w-5 shrink-0 text-gray-600 group-hover:text-purple-400 transition-colors" />
</div>
{/* Country + Motivation */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{actor.country && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-[11px] text-gray-400">
<Globe className="h-3 w-3" />
{actor.country}
</span>
)}
{actor.motivation && (
<MotivationBadge motivation={actor.motivation} size="sm" />
)}
</div>
{/* Sectors */}
{actor.target_sectors && actor.target_sectors.length > 0 && (
<div className="mt-2 flex items-center gap-1.5">
<Target className="h-3 w-3 text-gray-600 shrink-0" />
<span className="truncate text-[11px] text-gray-500">
{actor.target_sectors.slice(0, 3).join(", ")}
{actor.target_sectors.length > 3 && ` +${actor.target_sectors.length - 3}`}
</span>
</div>
)}
{/* Stats */}
<div className="mt-4 flex items-center justify-between border-t border-gray-800 pt-3">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Shield className="h-3.5 w-3.5" />
{actor.technique_count} techniques
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-gray-800">
<div
className={`h-full rounded-full ${coverageBg(actor.coverage_pct)}`}
style={{ width: `${Math.min(actor.coverage_pct, 100)}%` }}
/>
</div>
<span className={`text-xs font-medium ${coverageColor(actor.coverage_pct)}`}>
{actor.coverage_pct}%
</span>
</div>
</div>
</button>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">
Showing {page * limit + 1}{Math.min((page + 1) * limit, data.total)} of{" "}
{data.total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-700 bg-gray-800 p-2 text-gray-400 hover:text-white disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-gray-400">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-700 bg-gray-800 p-2 text-gray-400 hover:text-white disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</>
)}
{/* Empty */}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-12 text-center">
<Users className="mx-auto h-12 w-12 text-gray-600" />
<h3 className="mt-4 text-lg font-medium text-gray-300">No Threat Actors Found</h3>
<p className="mt-1 text-sm text-gray-500">
Import threat actors from MITRE CTI via the Data Sources panel.
</p>
</div>
)}
</div>
);
}