feat(phase-28): add scoring system, operational metrics and executive dashboard (T-224 to T-226)
This commit is contained in:
527
frontend/src/pages/ExecutiveDashboardPage.tsx
Normal file
527
frontend/src/pages/ExecutiveDashboardPage.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user