528 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|