fix(exec-dashboard): sort Top Threat Actors by uncovered techniques
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Previously: alphabetical order (first 5 actors from list_actors query).
Now: ranked by uncovered technique count = technique_count × (1 - coverage_pct/100).
Tiebreak: higher technique_count first (broader attack surface).

Fetches 100 actors, sorts client-side, shows top 5 with:
- Rank badge (1-5) colored red/orange/yellow/gray
- 'N uncovered / M techniques' subtitle instead of target sectors
- Coverage bar + percentage

This ensures the actors with the largest coverage gap appear first.
This commit is contained in:
kitos
2026-06-02 10:19:57 +02:00
parent 71141d9901
commit ba75baeb7d

View File

@@ -155,9 +155,10 @@ export default function ExecutiveDashboardPage() {
queryFn: getCoverageByTactic,
});
// Fetch enough actors to rank properly — sort client-side by uncovered techniques
const { data: threatActors } = useQuery({
queryKey: ["threat-actors-top"],
queryFn: () => getThreatActors({ limit: 5 }),
queryFn: () => getThreatActors({ limit: 100 }),
});
const { data: allTechniques } = useQuery({
@@ -322,29 +323,50 @@ export default function ExecutiveDashboardPage() {
{/* Section 3: Top Threat Actors */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
<h2 className="mb-1 text-sm font-semibold text-gray-300">
Top Threat Actors
</h2>
<p className="mb-3 text-[10px] text-gray-500">
Ranked by uncovered techniques (most exposure first)
</p>
<div className="space-y-2">
{(threatActors?.items || []).map((actor) => (
{[...(threatActors?.items || [])]
// Sort by uncovered technique count DESC (= technique_count × gap%)
// Tiebreak: higher technique_count = broader attack surface
.sort((a, b) => {
const uncoveredA = a.technique_count * (1 - a.coverage_pct / 100);
const uncoveredB = b.technique_count * (1 - b.coverage_pct / 100);
if (uncoveredB !== uncoveredA) return uncoveredB - uncoveredA;
return b.technique_count - a.technique_count;
})
.slice(0, 5)
.map((actor, idx) => (
<div
key={actor.id}
className="flex items-center gap-3 rounded-lg bg-gray-800/50 p-3 cursor-pointer hover:bg-gray-800"
onClick={() => navigate(`/threat-actors/${actor.id}`)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-700 text-xs font-bold text-gray-300">
{actor.country?.slice(0, 2).toUpperCase() || "??"}
{/* Rank badge */}
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${
idx === 0 ? "bg-red-500/20 text-red-400"
: idx === 1 ? "bg-orange-500/20 text-orange-400"
: idx === 2 ? "bg-yellow-500/20 text-yellow-400"
: "bg-gray-700 text-gray-400"
}`}>
{idx + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{actor.name}
</p>
<p className="text-[10px] text-gray-500 truncate">
{actor.target_sectors?.slice(0, 3).join(", ")}
{Math.round(actor.technique_count * (1 - actor.coverage_pct / 100))} uncovered
{" / "}
{actor.technique_count} techniques
</p>
</div>
<div className="flex items-center gap-2">
<div className="w-24 h-2 rounded-full bg-gray-700 overflow-hidden">
<div className="w-20 h-1.5 rounded-full bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
@@ -358,8 +380,8 @@ export default function ExecutiveDashboardPage() {
}}
/>
</div>
<span className="w-10 text-right text-xs font-medium text-gray-300">
{actor.coverage_pct}%
<span className="w-10 text-right text-xs font-medium text-gray-300 tabular-nums">
{actor.coverage_pct.toFixed(0)}%
</span>
</div>
</div>