531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Shield,
|
|
CheckCircle,
|
|
AlertTriangle,
|
|
XCircle,
|
|
Clock,
|
|
HelpCircle,
|
|
Percent,
|
|
Loader2,
|
|
AlertCircle,
|
|
Play,
|
|
Eye,
|
|
Users,
|
|
TrendingUp,
|
|
ArrowRight,
|
|
} from "lucide-react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend,
|
|
} from "recharts";
|
|
import {
|
|
getCoverageSummary,
|
|
getCoverageByTactic,
|
|
getTestPipeline,
|
|
getTeamActivity,
|
|
getValidationRate,
|
|
getRecentTests,
|
|
type TestPipelineCounts,
|
|
type TeamActivityItem,
|
|
type ValidationRateItem,
|
|
type RecentTestItem,
|
|
} from "../api/metrics";
|
|
import { getCoverageEvolution } from "../api/snapshots";
|
|
import CoverageSummaryCard from "../components/CoverageSummaryCard";
|
|
import TacticCoverageChart from "../components/TacticCoverageChart";
|
|
import type { TestState } from "../types/models";
|
|
|
|
/* ── Badge colours (reused from TestsPage) ─────────────────────────── */
|
|
|
|
const testStateBadgeColors: Record<string, string> = {
|
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
};
|
|
|
|
const testStateLabels: Record<string, string> = {
|
|
draft: "Draft",
|
|
red_executing: "Red Executing",
|
|
blue_evaluating: "Blue Evaluating",
|
|
in_review: "In Review",
|
|
validated: "Validated",
|
|
rejected: "Rejected",
|
|
};
|
|
|
|
/* ── Component ──────────────────────────────────────────────────────── */
|
|
|
|
export default function DashboardPage() {
|
|
const navigate = useNavigate();
|
|
|
|
// Existing coverage queries
|
|
const {
|
|
data: summary,
|
|
isLoading: summaryLoading,
|
|
error: summaryError,
|
|
} = useQuery({
|
|
queryKey: ["metrics", "summary"],
|
|
queryFn: getCoverageSummary,
|
|
});
|
|
|
|
const {
|
|
data: tactics,
|
|
isLoading: tacticsLoading,
|
|
error: tacticsError,
|
|
} = useQuery({
|
|
queryKey: ["metrics", "by-tactic"],
|
|
queryFn: getCoverageByTactic,
|
|
});
|
|
|
|
// V2 queries
|
|
const { data: pipeline, isLoading: pipelineLoading } = useQuery({
|
|
queryKey: ["metrics", "test-pipeline"],
|
|
queryFn: getTestPipeline,
|
|
});
|
|
|
|
const { data: teamActivity, isLoading: teamLoading } = useQuery({
|
|
queryKey: ["metrics", "team-activity"],
|
|
queryFn: getTeamActivity,
|
|
});
|
|
|
|
const { data: validationRates, isLoading: validationLoading } = useQuery({
|
|
queryKey: ["metrics", "validation-rate"],
|
|
queryFn: getValidationRate,
|
|
});
|
|
|
|
const { data: recentTests, isLoading: recentLoading } = useQuery({
|
|
queryKey: ["metrics", "recent-tests"],
|
|
queryFn: getRecentTests,
|
|
});
|
|
|
|
const { data: coverageEvolution, isLoading: evolutionLoading } = useQuery({
|
|
queryKey: ["snapshots", "evolution", 6],
|
|
queryFn: () => getCoverageEvolution(6),
|
|
});
|
|
|
|
if (summaryLoading || tacticsLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (summaryError || tacticsError) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
|
<p className="text-red-400">Failed to load metrics</p>
|
|
<p className="text-sm text-gray-500">
|
|
{summaryError?.message || tacticsError?.message || "Unknown error"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
MITRE ATT&CK coverage overview
|
|
</p>
|
|
</div>
|
|
{summary && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2">
|
|
<Percent className="h-5 w-5 text-cyan-400" />
|
|
<span className="text-lg font-bold text-cyan-400">
|
|
{summary.coverage_percentage.toFixed(1)}%
|
|
</span>
|
|
<span className="text-sm text-gray-400">Coverage</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
<CoverageSummaryCard
|
|
title="Total Techniques"
|
|
value={summary.total_techniques}
|
|
icon={<Shield className="h-6 w-6 text-cyan-400" />}
|
|
colorClass="text-cyan-400"
|
|
bgClass="bg-gray-900"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Validated"
|
|
value={summary.validated}
|
|
total={summary.total_techniques}
|
|
icon={<CheckCircle className="h-6 w-6 text-green-400" />}
|
|
colorClass="text-green-400"
|
|
bgClass="bg-green-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Partial"
|
|
value={summary.partial}
|
|
total={summary.total_techniques}
|
|
icon={<AlertTriangle className="h-6 w-6 text-yellow-400" />}
|
|
colorClass="text-yellow-400"
|
|
bgClass="bg-yellow-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="In Progress"
|
|
value={summary.in_progress}
|
|
total={summary.total_techniques}
|
|
icon={<Clock className="h-6 w-6 text-blue-400" />}
|
|
colorClass="text-blue-400"
|
|
bgClass="bg-blue-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Not Covered"
|
|
value={summary.not_covered}
|
|
total={summary.total_techniques}
|
|
icon={<XCircle className="h-6 w-6 text-red-400" />}
|
|
colorClass="text-red-400"
|
|
bgClass="bg-red-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Not Evaluated"
|
|
value={summary.not_evaluated}
|
|
total={summary.total_techniques}
|
|
icon={<HelpCircle className="h-6 w-6 text-gray-400" />}
|
|
colorClass="text-gray-400"
|
|
bgClass="bg-gray-900"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Coverage evolution (snapshots) */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5 text-cyan-400" />
|
|
Coverage Evolution (6 months)
|
|
</h2>
|
|
{evolutionLoading ? (
|
|
<div className="flex h-48 items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : coverageEvolution && coverageEvolution.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<LineChart data={coverageEvolution}>
|
|
<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" }}
|
|
/>
|
|
<Legend />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="org_score"
|
|
stroke="#06b6d4"
|
|
strokeWidth={2}
|
|
dot={{ r: 3 }}
|
|
name="Org score"
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="coverage_pct"
|
|
stroke="#22c55e"
|
|
strokeWidth={2}
|
|
dot={{ r: 3 }}
|
|
name="Coverage %"
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<p className="text-sm text-gray-500 py-8 text-center">
|
|
No snapshots yet. Weekly snapshots populate this chart automatically.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── V2 Section: Test Pipeline ────────────────────────────────── */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5 text-cyan-400" />
|
|
Test Pipeline
|
|
</h2>
|
|
<button
|
|
onClick={() => navigate("/tests")}
|
|
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
|
|
>
|
|
View all tests <ArrowRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{pipelineLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : pipeline ? (
|
|
<PipelineFunnel pipeline={pipeline} />
|
|
) : null}
|
|
</div>
|
|
|
|
{/* ── V2 Section: Team Activity + Validation Rate ──────────────── */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* Team Activity */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
|
|
<Users className="h-5 w-5 text-cyan-400" />
|
|
Team Activity
|
|
</h2>
|
|
|
|
{teamLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : teamActivity ? (
|
|
<div className="space-y-4">
|
|
{teamActivity.map((team: TeamActivityItem) => {
|
|
const isRed = team.team.toLowerCase().includes("red");
|
|
const total = team.tests_completed + team.tests_pending;
|
|
const pct = total > 0 ? (team.tests_completed / total) * 100 : 0;
|
|
return (
|
|
<div key={team.team} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`h-3 w-3 rounded-full ${
|
|
isRed ? "bg-red-500" : "bg-blue-500"
|
|
}`}
|
|
/>
|
|
<span className="font-medium text-white">{team.team}</span>
|
|
</div>
|
|
<span className="text-sm text-gray-400">
|
|
{team.tests_completed} completed / {team.tests_pending} pending
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-gray-700 overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
isRed ? "bg-red-500" : "bg-blue-500"
|
|
}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{pct.toFixed(0)}% completion rate
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Validation Rate */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
|
|
<CheckCircle className="h-5 w-5 text-cyan-400" />
|
|
Validation Rate
|
|
</h2>
|
|
|
|
{validationLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : validationRates ? (
|
|
<div className="space-y-4">
|
|
{validationRates.map((rate: ValidationRateItem) => {
|
|
const isRed = rate.role === "red_lead";
|
|
return (
|
|
<div key={rate.role} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`h-3 w-3 rounded-full ${
|
|
isRed ? "bg-red-500" : "bg-blue-500"
|
|
}`}
|
|
/>
|
|
<span className="font-medium text-white">
|
|
{isRed ? "Red Lead" : "Blue Lead"}
|
|
</span>
|
|
</div>
|
|
<span className="text-sm text-gray-400">
|
|
{rate.total_reviewed} reviewed
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-4 text-sm">
|
|
<div className="flex items-center gap-1">
|
|
<CheckCircle className="h-3.5 w-3.5 text-green-400" />
|
|
<span className="text-green-400">{rate.approved} approved</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<XCircle className="h-3.5 w-3.5 text-red-400" />
|
|
<span className="text-red-400">{rate.rejected} rejected</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 h-2 rounded-full bg-gray-700 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-green-500 transition-all"
|
|
style={{ width: `${rate.approval_rate}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{rate.approval_rate}% approval rate
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── V2 Section: Recent Tests ─────────────────────────────────── */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-white">Recent Tests</h2>
|
|
<button
|
|
onClick={() => navigate("/tests")}
|
|
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
|
|
>
|
|
View all <ArrowRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{recentLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : recentTests && recentTests.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800">
|
|
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
|
|
<th className="pb-3 pl-4 font-medium text-gray-400">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentTests.map((t: RecentTestItem) => (
|
|
<tr
|
|
key={t.id}
|
|
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
|
|
onClick={() => navigate(`/tests/${t.id}`)}
|
|
>
|
|
<td className="py-3 pr-4 font-medium text-gray-200">{t.name}</td>
|
|
<td className="py-3 px-4">
|
|
{t.technique_mitre_id ? (
|
|
<span className="font-mono text-xs text-cyan-400">
|
|
{t.technique_mitre_id}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-500">-</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
testStateBadgeColors[t.state] || "bg-gray-800 text-gray-400 border-gray-600"
|
|
}`}
|
|
>
|
|
{testStateLabels[t.state] || t.state}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 pl-4 text-gray-400 text-xs">
|
|
{t.created_at
|
|
? new Date(t.created_at).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
: "-"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center text-gray-400">
|
|
No tests created yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tactic Coverage Table (original V1) */}
|
|
{tactics && <TacticCoverageChart data={tactics} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Pipeline Funnel Sub-component ─────────────────────────────────── */
|
|
|
|
function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
|
|
const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
|
|
{ key: "draft", label: "Draft", color: "bg-gray-600", icon: <Clock className="h-4 w-4 text-gray-400" /> },
|
|
{ key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: <Play className="h-4 w-4 text-orange-400" /> },
|
|
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: <Shield className="h-4 w-4 text-indigo-400" /> },
|
|
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
|
|
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
|
|
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
|
|
];
|
|
|
|
const maxCount = Math.max(...stages.map((s) => pipeline[s.key] as number), 1);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{stages.map((stage) => {
|
|
const count = pipeline[stage.key] as number;
|
|
const pct = (count / maxCount) * 100;
|
|
return (
|
|
<div key={stage.key} className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2 w-36 shrink-0">
|
|
{stage.icon}
|
|
<span className="text-sm text-gray-300 truncate">{stage.label}</span>
|
|
</div>
|
|
<div className="flex-1 h-6 rounded bg-gray-800 overflow-hidden">
|
|
<div
|
|
className={`h-full rounded ${stage.color} transition-all flex items-center justify-end px-2`}
|
|
style={{ width: `${Math.max(pct, count > 0 ? 8 : 0)}%` }}
|
|
>
|
|
{count > 0 && (
|
|
<span className="text-xs font-medium text-white">{count}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-mono text-gray-400 w-8 text-right">{count}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="pt-2 border-t border-gray-800 flex justify-between text-sm">
|
|
<span className="text-gray-400">Total tests</span>
|
|
<span className="font-medium text-white">{pipeline.total}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|