feat(snapshots): evolution API, tactic breakdown and dashboard trend chart [FASE-5.2]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

This commit is contained in:
2026-05-18 15:07:12 +02:00
parent 05b221a22d
commit 1249391ef0
2 changed files with 106 additions and 0 deletions

View File

@@ -12,10 +12,28 @@ export interface SnapshotSummary {
not_covered_count: number; not_covered_count: number;
in_progress_count: number; in_progress_count: number;
not_evaluated_count: number; not_evaluated_count: number;
coverage_percentage?: number;
by_tactic?: Record<string, unknown>;
by_status?: Record<string, number>;
stale_count?: number;
never_tested_count?: number;
created_by: string | null; created_by: string | null;
created_at: string | null; created_at: string | null;
} }
export interface CoverageEvolutionPoint {
date: string | null;
name: string | null;
org_score: number;
coverage_pct: number;
by_tactic: Record<string, unknown>;
by_status: Record<string, number>;
stale_count: number;
never_tested_count: number;
validated_count: number;
total_techniques: number;
}
export interface TechniqueState { export interface TechniqueState {
mitre_id: string; mitre_id: string;
technique_id: string; technique_id: string;
@@ -76,6 +94,17 @@ export async function getSnapshot(snapshotId: string): Promise<SnapshotDetail> {
return data; return data;
} }
/** Coverage trend points for dashboard charts. */
export async function getCoverageEvolution(
months = 12,
): Promise<CoverageEvolutionPoint[]> {
const { data } = await client.get<CoverageEvolutionPoint[]>(
"/snapshots/evolution",
{ params: { months } },
);
return data;
}
/** Compare two snapshots. */ /** Compare two snapshots. */
export async function compareSnapshots( export async function compareSnapshots(
aId: string, aId: string,

View File

@@ -16,6 +16,16 @@ import {
TrendingUp, TrendingUp,
ArrowRight, ArrowRight,
} from "lucide-react"; } from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { import {
getCoverageSummary, getCoverageSummary,
getCoverageByTactic, getCoverageByTactic,
@@ -28,6 +38,7 @@ import {
type ValidationRateItem, type ValidationRateItem,
type RecentTestItem, type RecentTestItem,
} from "../api/metrics"; } from "../api/metrics";
import { getCoverageEvolution } from "../api/snapshots";
import CoverageSummaryCard from "../components/CoverageSummaryCard"; import CoverageSummaryCard from "../components/CoverageSummaryCard";
import TacticCoverageChart from "../components/TacticCoverageChart"; import TacticCoverageChart from "../components/TacticCoverageChart";
import type { TestState } from "../types/models"; import type { TestState } from "../types/models";
@@ -97,6 +108,11 @@ export default function DashboardPage() {
queryFn: getRecentTests, queryFn: getRecentTests,
}); });
const { data: coverageEvolution, isLoading: evolutionLoading } = useQuery({
queryKey: ["snapshots", "evolution", 6],
queryFn: () => getCoverageEvolution(6),
});
if (summaryLoading || tacticsLoading) { if (summaryLoading || tacticsLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -191,6 +207,67 @@ export default function DashboardPage() {
</div> </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 ────────────────────────────────── */} {/* ── V2 Section: Test Pipeline ────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">