+ {/* Header */}
+
+
+
+
+
+
+
Temporal Comparison
+
Compare coverage snapshots over time
+
+
+
+
+ {/* Snapshot selectors */}
+
+
+ {/* Snapshot A */}
+
+
+
+
+
+ {/* Snapshot B */}
+
+
+
+
+
+
+ {isLoadingSnapshots && (
+
+
+ Loading snapshots...
+
+ )}
+
+
+ {/* Loading / Error */}
+ {isComparing && (
+
+
+
+ )}
+ {compareError && (
+
+
+ Failed to compare snapshots
+
+ )}
+
+ {/* Comparison results */}
+ {comparison && (
+ <>
+ {/* Side-by-side score cards */}
+
+ {/* Snapshot A card */}
+
+
+
+
+ {comparison.snapshot_a.name || "Snapshot A"}
+
+
+ {formatDate(comparison.snapshot_a.created_at)}
+
+
+
+ {comparison.snapshot_a.organization_score}
+
+
+
+
+
+
+
+
+
+ {/* Snapshot B card */}
+
+
+
+
+ {comparison.snapshot_b.name || "Snapshot B"}
+
+
+ {formatDate(comparison.snapshot_b.created_at)}
+
+
+
+
+ {comparison.snapshot_b.organization_score}
+
+ {comparison.score_delta !== 0 && (
+ 0 ? "text-green-400" : "text-red-400"
+ }`}
+ >
+ {comparison.score_delta > 0 ? (
+
+ ) : (
+
+ )}
+ {comparison.score_delta > 0 ? "+" : ""}
+ {comparison.score_delta}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+
+
+
+
+
+
+ {activeTab === "unchanged" ? (
+
+
+
+ {comparison.unchanged_count} techniques unchanged
+
+
+ These techniques had the same status and score in both snapshots.
+
+
+ ) : (
+ <>
+ {(activeTab === "improved"
+ ? tabData.improved
+ : tabData.worsened
+ ).length === 0 ? (
+
+
No techniques {activeTab} between snapshots.
+
+ ) : (
+
+
+
+
+ | MITRE ID |
+ Before |
+ After |
+ Score Before |
+ Score After |
+ Delta |
+
+
+
+ {(activeTab === "improved"
+ ? tabData.improved
+ : tabData.worsened
+ ).map((item) => (
+
+ navigate(`/techniques/${item.mitre_id}`)
+ }
+ >
+ |
+
+ {item.mitre_id}
+
+ |
+
+
+ |
+
+
+ |
+
+ {item.old_score}
+ |
+
+ {item.new_score}
+ |
+
+ item.old_score
+ ? "text-green-400"
+ : item.new_score < item.old_score
+ ? "text-red-400"
+ : "text-gray-500"
+ }`}
+ >
+
+ {item.new_score > item.old_score ? "+" : ""}
+ {Math.round((item.new_score - item.old_score) * 10) / 10}
+
+ |
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+
+ >
+ )}
+
+ {/* No selection prompt */}
+ {!comparison && !isComparing && !compareError && (
+
+
+
Select two snapshots to compare
+
+ Choose a baseline and current snapshot from the dropdowns above.
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx
index 075ff92..fb1768c 100644
--- a/frontend/src/pages/TestDetailPage.tsx
+++ b/frontend/src/pages/TestDetailPage.tsx
@@ -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() {
)}
+
+ {/* Retest Chain */}
+ {(test.retest_of || test.retest_count > 0 || retestChain.length > 1) && (
+