feat(phase-30): add coverage snapshots, temporal comparison and auto re-testing (T-230 to T-232)
This commit is contained in:
@@ -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"
|
||||
|
||||
93
frontend/src/api/snapshots.ts
Normal file
93
frontend/src/api/snapshots.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
458
frontend/src/pages/ComparisonPage.tsx
Normal file
458
frontend/src/pages/ComparisonPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user