Files
Aegis/frontend/src/pages/ExecutiveDashboardPage.tsx

528 lines
18 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Loader2,
AlertCircle,
TrendingUp,
TrendingDown,
Minus,
ArrowRight,
} from "lucide-react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { getOrganizationScore, getScoreHistory } from "../api/scores";
import {
getOperationalMetrics,
getMetricsByTeam,
} from "../api/operational-metrics";
import { getCoverageByTactic } from "../api/metrics";
import { getThreatActors } from "../api/threat-actors";
import { getTechniques, type TechniqueSummary } from "../api/techniques";
// ── Score Gauge Component ────────────────────────────────────────────
function ScoreGauge({ score, label }: { score: number; label: string }) {
const getColor = (s: number) => {
if (s < 30) return "#ef4444";
if (s < 50) return "#f97316";
if (s < 70) return "#eab308";
return "#22c55e";
};
const color = getColor(score);
const circumference = 2 * Math.PI * 54;
const strokeDasharray = `${(score / 100) * circumference} ${circumference}`;
return (
<div className="flex flex-col items-center">
<div className="relative h-32 w-32">
<svg className="h-32 w-32 -rotate-90" viewBox="0 0 120 120">
<circle
cx="60"
cy="60"
r="54"
fill="none"
stroke="#1f2937"
strokeWidth="8"
/>
<circle
cx="60"
cy="60"
r="54"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={strokeDasharray}
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-white">{Math.round(score)}</span>
<span className="text-[10px] text-gray-500">/ 100</span>
</div>
</div>
<span className="mt-2 text-xs font-medium text-gray-400">{label}</span>
</div>
);
}
// ── KPI Card Component ──────────────────────────────────────────────
function KPICard({
label,
value,
unit,
trend,
}: {
label: string;
value: string | number;
unit?: string;
trend?: "improving" | "declining" | "stable" | null;
}) {
const TrendIcon =
trend === "improving"
? TrendingUp
: trend === "declining"
? TrendingDown
: Minus;
const trendColor =
trend === "improving"
? "text-green-400"
: trend === "declining"
? "text-red-400"
: "text-gray-500";
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{label}</p>
<div className="mt-2 flex items-end justify-between">
<div>
<span className="text-2xl font-bold text-white">
{value === null || value === undefined ? "N/A" : value}
</span>
{unit && <span className="ml-1 text-sm text-gray-500">{unit}</span>}
</div>
{trend && (
<TrendIcon className={`h-5 w-5 ${trendColor}`} />
)}
</div>
</div>
);
}
// ── Main Component ──────────────────────────────────────────────────
export default function ExecutiveDashboardPage() {
const navigate = useNavigate();
const { data: orgScore, isLoading: loadingScore } = useQuery({
queryKey: ["org-score"],
queryFn: getOrganizationScore,
});
const { data: scoreHistory } = useQuery({
queryKey: ["score-history", "90d"],
queryFn: () => getScoreHistory("90d"),
});
const { data: opMetrics, isLoading: loadingMetrics } = useQuery({
queryKey: ["operational-metrics"],
queryFn: getOperationalMetrics,
});
const { data: teamMetrics } = useQuery({
queryKey: ["team-metrics"],
queryFn: getMetricsByTeam,
});
const { data: tacticCoverage } = useQuery({
queryKey: ["tactic-coverage"],
queryFn: getCoverageByTactic,
});
const { data: threatActors } = useQuery({
queryKey: ["threat-actors-top"],
queryFn: () => getThreatActors({ limit: 5 }),
});
const { data: allTechniques } = useQuery({
queryKey: ["techniques-exec"],
queryFn: () => getTechniques(),
});
const isLoading = loadingScore || loadingMetrics;
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
// Critical gaps: not_covered or not_evaluated techniques
const criticalGaps: TechniqueSummary[] = (allTechniques || [])
.filter((t) => t.status_global === "not_covered" || t.status_global === "not_evaluated")
.slice(0, 10);
// Coverage by tactic for bar chart
const tacticData = (tacticCoverage || []).map((tc) => ({
name: tc.tactic
.split("-")
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "),
coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0,
}));
const getBarColor = (coverage: number) => {
if (coverage < 30) return "#ef4444";
if (coverage < 50) return "#f97316";
if (coverage < 70) return "#eab308";
return "#22c55e";
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Executive Dashboard</h1>
<p className="mt-1 text-sm text-gray-400">
Organization security posture overview
</p>
</div>
{/* Section 1: Score Card + Sub-scores */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6 lg:col-span-1 flex flex-col items-center justify-center">
<ScoreGauge
score={orgScore?.overall_score ?? 0}
label="Overall Score"
/>
<div className="mt-4 grid w-full grid-cols-2 gap-2">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.total_coverage ?? 0}
</p>
<p className="text-[10px] text-gray-500">Coverage</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.detection_maturity ?? 0}
</p>
<p className="text-[10px] text-gray-500">Detection</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.critical_coverage ?? 0}
</p>
<p className="text-[10px] text-gray-500">Critical</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.response_readiness ?? 0}
</p>
<p className="text-[10px] text-gray-500">Response</p>
</div>
</div>
</div>
{/* Section 2: Trend Chart */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 lg:col-span-3">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Score Trend (90 days)
</h2>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={scoreHistory || []}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis
dataKey="date"
tick={{ fill: "#6b7280", fontSize: 10 }}
tickFormatter={(val) => {
const d = new Date(val);
return `${d.getMonth() + 1}/${d.getDate()}`;
}}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: "#6b7280", fontSize: 10 }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#111827",
border: "1px solid #374151",
borderRadius: "8px",
fontSize: "12px",
}}
labelStyle={{ color: "#9ca3af" }}
/>
<Line
type="monotone"
dataKey="score"
stroke="#06b6d4"
strokeWidth={2}
dot={false}
name="Score"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* 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">
Top Threat Actors
</h2>
<div className="space-y-2">
{(threatActors?.items || []).map((actor) => (
<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() || "??"}
</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(", ")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="w-24 h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${actor.coverage_pct}%`,
backgroundColor:
actor.coverage_pct > 70
? "#22c55e"
: actor.coverage_pct > 40
? "#eab308"
: "#ef4444",
}}
/>
</div>
<span className="w-10 text-right text-xs font-medium text-gray-300">
{actor.coverage_pct}%
</span>
</div>
</div>
))}
</div>
</div>
{/* Section 4: Operational KPIs */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KPICard
label="MTTD"
value={opMetrics?.mttd?.mean_hours ?? "N/A"}
unit={opMetrics?.mttd ? "hrs" : undefined}
/>
<KPICard
label="MTTR"
value={opMetrics?.mttr?.mean_hours ?? "N/A"}
unit={opMetrics?.mttr ? "hrs" : undefined}
/>
<KPICard
label="Detection Efficacy"
value={opMetrics?.detection_efficacy?.percentage ?? 0}
unit="%"
/>
<KPICard
label="Validation Throughput"
value={opMetrics?.validation_throughput?.tests_per_week ?? 0}
unit="/week"
trend={opMetrics?.validation_throughput?.trend}
/>
</div>
{/* Section 5: Coverage by Tactic */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Coverage by Tactic
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={tacticData}
layout="vertical"
margin={{ left: 120 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis
type="number"
domain={[0, 100]}
tick={{ fill: "#6b7280", fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
/>
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fill: "#9ca3af", fontSize: 10 }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#111827",
border: "1px solid #374151",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value: number) => [`${value}%`, "Coverage"]}
/>
<Bar dataKey="coverage" radius={[0, 4, 4, 0]}>
{tacticData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getBarColor(entry.coverage)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Section 6: Critical Gaps */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Critical Gaps (Top 10 Uncovered Techniques)
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-left text-xs text-gray-500">
<th className="pb-2 pr-4">MITRE ID</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Tactic</th>
<th className="pb-2 pr-4">Status</th>
</tr>
</thead>
<tbody>
{criticalGaps.map((tech) => (
<tr
key={tech.mitre_id}
className="border-b border-gray-800/50 cursor-pointer hover:bg-gray-800/30"
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
>
<td className="py-2 pr-4 font-mono text-xs text-cyan-400">
{tech.mitre_id}
</td>
<td className="py-2 pr-4 text-gray-300 truncate max-w-[200px]">
{tech.name}
</td>
<td className="py-2 pr-4 text-gray-500 text-xs">
{tech.tactic
?.split(",")[0]
.trim()
.split("-")
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</td>
<td className="py-2 pr-4">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
tech.status_global === "not_covered"
? "bg-red-500/10 text-red-400"
: "bg-gray-500/10 text-gray-400"
}`}
>
{tech.status_global?.replace(/_/g, " ")}
</span>
</td>
</tr>
))}
{criticalGaps.length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-gray-500">
No critical gaps found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Section 7: Team Performance */}
{teamMetrics && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Red Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-400">
<div className="h-2 w-2 rounded-full bg-red-500" />
Red Team
</h2>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.tests_completed}
</p>
<p className="text-[10px] text-gray-500">Tests Done</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.avg_completion_hours
? `${teamMetrics.red_team.avg_completion_hours}h`
: "N/A"}
</p>
<p className="text-[10px] text-gray-500">Avg Time</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.rejection_rate}%
</p>
<p className="text-[10px] text-gray-500">Rejection</p>
</div>
</div>
</div>
{/* Blue Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-blue-400">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Blue Team
</h2>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.tests_completed}
</p>
<p className="text-[10px] text-gray-500">Tests Done</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.avg_completion_hours
? `${teamMetrics.blue_team.avg_completion_hours}h`
: "N/A"}
</p>
<p className="text-[10px] text-gray-500">Avg Time</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.rejection_rate}%
</p>
<p className="text-[10px] text-gray-500">Rejection</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}