fix(exec-dashboard): sort Top Threat Actors by uncovered techniques
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -155,9 +155,10 @@ export default function ExecutiveDashboardPage() {
|
|||||||
queryFn: getCoverageByTactic,
|
queryFn: getCoverageByTactic,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch enough actors to rank properly — sort client-side by uncovered techniques
|
||||||
const { data: threatActors } = useQuery({
|
const { data: threatActors } = useQuery({
|
||||||
queryKey: ["threat-actors-top"],
|
queryKey: ["threat-actors-top"],
|
||||||
queryFn: () => getThreatActors({ limit: 5 }),
|
queryFn: () => getThreatActors({ limit: 100 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allTechniques } = useQuery({
|
const { data: allTechniques } = useQuery({
|
||||||
@@ -322,29 +323,50 @@ export default function ExecutiveDashboardPage() {
|
|||||||
|
|
||||||
{/* Section 3: Top Threat Actors */}
|
{/* Section 3: Top Threat Actors */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
<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
|
Top Threat Actors
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mb-3 text-[10px] text-gray-500">
|
||||||
|
Ranked by uncovered techniques (most exposure first)
|
||||||
|
</p>
|
||||||
<div className="space-y-2">
|
<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
|
<div
|
||||||
key={actor.id}
|
key={actor.id}
|
||||||
className="flex items-center gap-3 rounded-lg bg-gray-800/50 p-3 cursor-pointer hover:bg-gray-800"
|
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}`)}
|
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">
|
{/* Rank badge */}
|
||||||
{actor.country?.slice(0, 2).toUpperCase() || "??"}
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">
|
<p className="text-sm font-medium text-white truncate">
|
||||||
{actor.name}
|
{actor.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-gray-500 truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<div
|
||||||
className="h-full rounded-full transition-all"
|
className="h-full rounded-full transition-all"
|
||||||
style={{
|
style={{
|
||||||
@@ -358,8 +380,8 @@ export default function ExecutiveDashboardPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 text-right text-xs font-medium text-gray-300">
|
<span className="w-10 text-right text-xs font-medium text-gray-300 tabular-nums">
|
||||||
{actor.coverage_pct}%
|
{actor.coverage_pct.toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user