Files
Aegis/frontend/src/pages/ComparisonPage.tsx

459 lines
18 KiB
TypeScript

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>
);
}