feat(phase-30): add coverage snapshots, temporal comparison and auto re-testing (T-230 to T-232)

This commit is contained in:
2026-02-10 08:34:29 +01:00
parent 2ac8e7f4a5
commit 4d124b42dd
20 changed files with 1517 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ import ThreatActorsPage from "./pages/ThreatActorsPage";
import ThreatActorDetailPage from "./pages/ThreatActorDetailPage";
import CampaignsPage from "./pages/CampaignsPage";
import CampaignDetailPage from "./pages/CampaignDetailPage";
import ComparisonPage from "./pages/ComparisonPage";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
@@ -58,6 +59,7 @@ export default function App() {
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="/comparison" element={<ComparisonPage />} />
<Route path="/compliance" element={<CompliancePage />} />
<Route
path="/system"

View File

@@ -0,0 +1,93 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export interface SnapshotSummary {
id: string;
name: string | null;
organization_score: number;
total_techniques: number;
validated_count: number;
partial_count: number;
not_covered_count: number;
in_progress_count: number;
not_evaluated_count: number;
created_by: string | null;
created_at: string | null;
}
export interface TechniqueState {
mitre_id: string;
technique_id: string;
status: string;
score: number | null;
}
export interface SnapshotDetail extends SnapshotSummary {
technique_states: TechniqueState[];
}
export interface SnapshotComparisonDelta {
mitre_id: string;
old_status: string;
new_status: string;
old_score: number;
new_score: number;
}
export interface SnapshotComparison {
snapshot_a: SnapshotSummary;
snapshot_b: SnapshotSummary;
score_delta: number;
improved: SnapshotComparisonDelta[];
worsened: SnapshotComparisonDelta[];
unchanged_count: number;
summary: {
improved_count: number;
worsened_count: number;
new_count: number;
};
}
// ── API Functions ───────────────────────────────────────────────────
/** List snapshots (paginated, newest first). */
export async function listSnapshots(params?: {
offset?: number;
limit?: number;
}): Promise<{ total: number; items: SnapshotSummary[] }> {
const { data } = await client.get("/snapshots", { params });
return data;
}
/** Create a manual snapshot. */
export async function createSnapshot(name?: string): Promise<SnapshotSummary> {
const { data } = await client.post<SnapshotSummary>("/snapshots", {
name: name || null,
});
return data;
}
/** Get snapshot detail with per-technique states. */
export async function getSnapshot(snapshotId: string): Promise<SnapshotDetail> {
const { data } = await client.get<SnapshotDetail>(
`/snapshots/${snapshotId}`,
);
return data;
}
/** Compare two snapshots. */
export async function compareSnapshots(
aId: string,
bId: string,
): Promise<SnapshotComparison> {
const { data } = await client.get<SnapshotComparison>("/snapshots/compare", {
params: { a: aId, b: bId },
});
return data;
}
/** Delete a snapshot (admin only). */
export async function deleteSnapshot(snapshotId: string): Promise<void> {
await client.delete(`/snapshots/${snapshotId}`);
}

View File

@@ -204,6 +204,26 @@ export async function getTestTimeline(
return data;
}
// ── Retest Chain ────────────────────────────────────────────────────
export interface RetestChainEntry {
id: string;
name: string;
state: string | null;
retest_of: string | null;
retest_count: number;
result: string | null;
detection_result: string | null;
remediation_status: string | null;
created_at: string | null;
}
/** Get the full retest chain for a test. */
export async function getRetestChain(testId: string): Promise<RetestChainEntry[]> {
const { data } = await client.get<RetestChainEntry[]>(`/tests/${testId}/retest-chain`);
return data;
}
// ── Legacy (kept for backwards compat) ─────────────────────────────
/** Validate a test (legacy endpoint). */

View File

@@ -18,6 +18,7 @@ import {
Grid3X3,
Gauge,
ShieldCheck,
GitCompareArrows,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -46,6 +47,7 @@ const mainLinks: NavItem[] = [
{ to: "/reports", label: "Reports", icon: BarChart3 },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/campaigns", label: "Campaigns", icon: Zap },
{ to: "/comparison", label: "Comparison", icon: GitCompareArrows },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
];

View File

@@ -0,0 +1,458 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
ArrowUp,
ArrowDown,
Minus,
GitCompareArrows,
Camera,
TrendingUp,
TrendingDown,
} from "lucide-react";
import {
listSnapshots,
compareSnapshots,
type SnapshotSummary,
type SnapshotComparison,
} from "../api/snapshots";
type Tab = "improved" | "worsened" | "unchanged";
const statusColors: Record<string, string> = {
validated: "text-green-400",
partial: "text-yellow-400",
not_covered: "text-red-400",
in_progress: "text-blue-400",
not_evaluated: "text-gray-500",
};
const statusDots: Record<string, string> = {
validated: "bg-green-400",
partial: "bg-yellow-400",
not_covered: "bg-red-400",
in_progress: "bg-blue-400",
not_evaluated: "bg-gray-500",
};
function StatusBadge({ status }: { status: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={`h-2 w-2 rounded-full ${statusDots[status] || statusDots.not_evaluated}`} />
<span className={statusColors[status] || statusColors.not_evaluated}>
{status.replace(/_/g, " ")}
</span>
</span>
);
}
function DeltaArrow({ delta }: { delta: number }) {
if (delta > 0) return <ArrowUp className="h-3.5 w-3.5 text-green-400" />;
if (delta < 0) return <ArrowDown className="h-3.5 w-3.5 text-red-400" />;
return <Minus className="h-3.5 w-3.5 text-gray-500" />;
}
function MetricCard({
label,
valueA,
valueB,
suffix,
}: {
label: string;
valueA: number;
valueB: number;
suffix?: string;
}) {
const delta = valueB - valueA;
const deltaColor =
delta > 0 ? "text-green-400" : delta < 0 ? "text-red-400" : "text-gray-500";
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-gray-500">{label}</span>
<div className="flex items-baseline gap-2">
<span className="text-lg font-bold text-white">
{valueB}
{suffix}
</span>
{delta !== 0 && (
<span className={`flex items-center gap-0.5 text-xs font-medium ${deltaColor}`}>
<DeltaArrow delta={delta} />
{delta > 0 ? "+" : ""}
{delta}
</span>
)}
</div>
</div>
);
}
export default function ComparisonPage() {
const navigate = useNavigate();
const [snapA, setSnapA] = useState<string>("");
const [snapB, setSnapB] = useState<string>("");
const [activeTab, setActiveTab] = useState<Tab>("improved");
// Fetch all snapshots for the dropdowns
const { data: snapshotsData, isLoading: isLoadingSnapshots } = useQuery({
queryKey: ["snapshots", "all"],
queryFn: () => listSnapshots({ limit: 200 }),
});
const snapshots = snapshotsData?.items || [];
// Comparison query
const {
data: comparison,
isLoading: isComparing,
error: compareError,
} = useQuery({
queryKey: ["snapshot-compare", snapA, snapB],
queryFn: () => compareSnapshots(snapA, snapB),
enabled: !!snapA && !!snapB && snapA !== snapB,
});
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// For the "unchanged" tab, we don't get individual rows from the API,
// just a count, so we show the count.
const tabData = useMemo(() => {
if (!comparison) return { improved: [], worsened: [], unchanged_count: 0 };
return {
improved: comparison.improved,
worsened: comparison.worsened,
unchanged_count: comparison.unchanged_count,
};
}, [comparison]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-cyan-500/10 p-2.5">
<GitCompareArrows className="h-6 w-6 text-cyan-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Temporal Comparison</h1>
<p className="text-sm text-gray-400">Compare coverage snapshots over time</p>
</div>
</div>
</div>
{/* Snapshot selectors */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Snapshot A */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-400">
Snapshot A (Baseline)
</label>
<select
value={snapA}
onChange={(e) => setSnapA(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm text-white focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
<option value="">Select snapshot...</option>
{snapshots.map((s) => (
<option key={s.id} value={s.id}>
{s.name || `Snapshot ${formatDate(s.created_at)}`} Score:{" "}
{s.organization_score}
</option>
))}
</select>
</div>
{/* Snapshot B */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-400">
Snapshot B (Current)
</label>
<select
value={snapB}
onChange={(e) => setSnapB(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm text-white focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
<option value="">Select snapshot...</option>
{snapshots.map((s) => (
<option key={s.id} value={s.id}>
{s.name || `Snapshot ${formatDate(s.created_at)}`} Score:{" "}
{s.organization_score}
</option>
))}
</select>
</div>
</div>
{isLoadingSnapshots && (
<div className="mt-4 flex items-center gap-2 text-sm text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" />
Loading snapshots...
</div>
)}
</div>
{/* Loading / Error */}
{isComparing && (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
)}
{compareError && (
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-900/30 px-4 py-3 text-sm text-red-400">
<AlertCircle className="h-4 w-4" />
Failed to compare snapshots
</div>
)}
{/* Comparison results */}
{comparison && (
<>
{/* Side-by-side score cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Snapshot A card */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<Camera className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-400">
{comparison.snapshot_a.name || "Snapshot A"}
</span>
<span className="ml-auto text-xs text-gray-600">
{formatDate(comparison.snapshot_a.created_at)}
</span>
</div>
<div className="text-3xl font-bold text-white">
{comparison.snapshot_a.organization_score}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<MetricCard
label="Validated"
valueA={comparison.snapshot_a.validated_count}
valueB={comparison.snapshot_a.validated_count}
/>
<MetricCard
label="Partial"
valueA={comparison.snapshot_a.partial_count}
valueB={comparison.snapshot_a.partial_count}
/>
<MetricCard
label="Not Covered"
valueA={comparison.snapshot_a.not_covered_count}
valueB={comparison.snapshot_a.not_covered_count}
/>
<MetricCard
label="In Progress"
valueA={comparison.snapshot_a.in_progress_count}
valueB={comparison.snapshot_a.in_progress_count}
/>
</div>
</div>
{/* Snapshot B card */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<Camera className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-400">
{comparison.snapshot_b.name || "Snapshot B"}
</span>
<span className="ml-auto text-xs text-gray-600">
{formatDate(comparison.snapshot_b.created_at)}
</span>
</div>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-bold text-white">
{comparison.snapshot_b.organization_score}
</span>
{comparison.score_delta !== 0 && (
<span
className={`flex items-center gap-1 text-sm font-semibold ${
comparison.score_delta > 0 ? "text-green-400" : "text-red-400"
}`}
>
{comparison.score_delta > 0 ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{comparison.score_delta > 0 ? "+" : ""}
{comparison.score_delta}
</span>
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<MetricCard
label="Validated"
valueA={comparison.snapshot_a.validated_count}
valueB={comparison.snapshot_b.validated_count}
/>
<MetricCard
label="Partial"
valueA={comparison.snapshot_a.partial_count}
valueB={comparison.snapshot_b.partial_count}
/>
<MetricCard
label="Not Covered"
valueA={comparison.snapshot_a.not_covered_count}
valueB={comparison.snapshot_b.not_covered_count}
/>
<MetricCard
label="In Progress"
valueA={comparison.snapshot_a.in_progress_count}
valueB={comparison.snapshot_b.in_progress_count}
/>
</div>
</div>
</div>
{/* Tabs */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<div className="flex border-b border-gray-800">
<button
onClick={() => setActiveTab("improved")}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === "improved"
? "border-b-2 border-green-400 text-green-400"
: "text-gray-400 hover:text-white"
}`}
>
<ArrowUp className="h-4 w-4" />
Improved ({comparison.summary.improved_count})
</button>
<button
onClick={() => setActiveTab("worsened")}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === "worsened"
? "border-b-2 border-red-400 text-red-400"
: "text-gray-400 hover:text-white"
}`}
>
<ArrowDown className="h-4 w-4" />
Worsened ({comparison.summary.worsened_count})
</button>
<button
onClick={() => setActiveTab("unchanged")}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === "unchanged"
? "border-b-2 border-gray-400 text-gray-400"
: "text-gray-500 hover:text-white"
}`}
>
<Minus className="h-4 w-4" />
Unchanged ({comparison.unchanged_count})
</button>
</div>
<div className="p-6">
{activeTab === "unchanged" ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<Minus className="mb-2 h-8 w-8 text-gray-600" />
<p className="text-lg font-medium">
{comparison.unchanged_count} techniques unchanged
</p>
<p className="mt-1 text-sm text-gray-500">
These techniques had the same status and score in both snapshots.
</p>
</div>
) : (
<>
{(activeTab === "improved"
? tabData.improved
: tabData.worsened
).length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<p className="text-sm">No techniques {activeTab} between snapshots.</p>
</div>
) : (
<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">MITRE ID</th>
<th className="pb-3 px-4 font-medium text-gray-400">Before</th>
<th className="pb-3 px-4 font-medium text-gray-400">After</th>
<th className="pb-3 px-4 font-medium text-gray-400">Score Before</th>
<th className="pb-3 px-4 font-medium text-gray-400">Score After</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Delta</th>
</tr>
</thead>
<tbody>
{(activeTab === "improved"
? tabData.improved
: tabData.worsened
).map((item) => (
<tr
key={item.mitre_id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() =>
navigate(`/techniques/${item.mitre_id}`)
}
>
<td className="py-3 pr-4">
<span className="font-mono text-xs text-cyan-400">
{item.mitre_id}
</span>
</td>
<td className="py-3 px-4">
<StatusBadge status={item.old_status} />
</td>
<td className="py-3 px-4">
<StatusBadge status={item.new_status} />
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400">{item.old_score}</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-white">{item.new_score}</span>
</td>
<td className="py-3 pl-4">
<span
className={`flex items-center gap-1 text-xs font-medium ${
item.new_score > item.old_score
? "text-green-400"
: item.new_score < item.old_score
? "text-red-400"
: "text-gray-500"
}`}
>
<DeltaArrow delta={item.new_score - item.old_score} />
{item.new_score > item.old_score ? "+" : ""}
{Math.round((item.new_score - item.old_score) * 10) / 10}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
</>
)}
{/* No selection prompt */}
{!comparison && !isComparing && !compareError && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<GitCompareArrows className="mb-3 h-12 w-12 text-gray-600" />
<p className="text-lg font-medium">Select two snapshots to compare</p>
<p className="mt-1 text-sm text-gray-500">
Choose a baseline and current snapshot from the dropdowns above.
</p>
</div>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
validateAsBlueLead,
reopenTest,
getTestTimeline,
getRetestChain,
} from "../api/tests";
import { uploadEvidence, getEvidence } from "../api/evidence";
import { useAuth } from "../context/AuthContext";
@@ -79,6 +80,12 @@ export default function TestDetailPage() {
enabled: !!testId,
});
const { data: retestChain = [] } = useQuery({
queryKey: ["retest-chain", testId],
queryFn: () => getRetestChain(testId!),
enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0),
});
// Hydrate drafts from test data
useEffect(() => {
if (test) {
@@ -442,6 +449,55 @@ export default function TestDetailPage() {
)}
</dl>
</div>
{/* Retest Chain */}
{(test.retest_of || test.retest_count > 0 || retestChain.length > 1) && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Retest Chain</h2>
{test.retest_of && (
<div className="mb-3">
<span className="text-xs font-medium uppercase text-gray-500">
Retest {test.retest_count} / 3
</span>
<div className="mt-1 h-2 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-cyan-500 transition-all"
style={{ width: `${(test.retest_count / 3) * 100}%` }}
/>
</div>
</div>
)}
<div className="space-y-2">
{retestChain.map((entry) => (
<button
key={entry.id}
onClick={() => entry.id !== testId && navigate(`/tests/${entry.id}`)}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
entry.id === testId
? "border-cyan-500/30 bg-cyan-900/30 text-cyan-400"
: "border-gray-700 bg-gray-800/50 text-gray-300 hover:border-cyan-500/30 hover:text-cyan-400"
}`}
>
<div className="flex items-center gap-2 truncate">
<span className="truncate text-xs">
{entry.retest_of ? `#${entry.retest_count}` : "Original"}
</span>
<span className="truncate">{entry.name}</span>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs ${
entry.state === "validated"
? "bg-green-900/50 text-green-400"
: entry.state === "rejected"
? "bg-red-900/50 text-red-400"
: "bg-gray-800/50 text-gray-500"
}`}>
{entry.state || "draft"}
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>

View File

@@ -91,6 +91,10 @@ export interface Test {
remediation_status: string | null;
remediation_assignee: string | null;
// Re-test fields
retest_of: string | null;
retest_count: number;
// Technique info (populated in list endpoints)
technique_mitre_id: string | null;
technique_name: string | null;