feat(tests): add Validated Tests as dedicated page, remove duplicate sidebar entry
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- New /tests/validated page with its own route and sidebar link, showing only validated tests with Attack and Detection result badges. - Removed the duplicate "My Pending Tasks" sidebar entry (same as All Tests). - All Tests table no longer shows validated tests; clicking the Validated counter card navigates to the new page instead. - Validated option removed from the state filter dropdown in All Tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
310
frontend/src/pages/ValidatedTestsPage.tsx
Normal file
310
frontend/src/pages/ValidatedTestsPage.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Search,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ShieldCheck,
|
||||
Swords,
|
||||
} from "lucide-react";
|
||||
import { getTests } from "../api/tests";
|
||||
import type { Test, TestResult } from "../types/models";
|
||||
|
||||
/* ── Result helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
const detectionColors: Record<TestResult, string> = {
|
||||
detected: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
partially_detected: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
not_detected: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
};
|
||||
|
||||
const detectionLabels: Record<TestResult, string> = {
|
||||
detected: "Detected",
|
||||
partially_detected: "Partial",
|
||||
not_detected: "Not Detected",
|
||||
};
|
||||
|
||||
function DetectionBadge({ result }: { result: TestResult | null | undefined }) {
|
||||
if (!result) return <span className="text-gray-600 text-xs">—</span>;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${detectionColors[result]}`}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{detectionLabels[result]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AttackBadge({ success }: { success: boolean | null | undefined }) {
|
||||
if (success === null || success === undefined)
|
||||
return <span className="text-gray-600 text-xs">—</span>;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
success
|
||||
? "bg-orange-500/10 text-orange-400 border-orange-500/20"
|
||||
: "bg-gray-500/10 text-gray-400 border-gray-600/20"
|
||||
}`}
|
||||
>
|
||||
<Swords className="h-3 w-3" />
|
||||
{success ? "Success" : "Blocked"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────────── */
|
||||
|
||||
export default function ValidatedTestsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
type SortKey =
|
||||
| "name"
|
||||
| "technique"
|
||||
| "platform"
|
||||
| "attack_success"
|
||||
| "detection_result"
|
||||
| "created_at"
|
||||
| "updated_at";
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>("updated_at");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const { data: validatedTests, isLoading, error } = useQuery({
|
||||
queryKey: ["tests", "validated"],
|
||||
queryFn: () => getTests({ state: "validated", limit: 200 }),
|
||||
});
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return "—";
|
||||
const utc =
|
||||
dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
||||
return new Date(utc).toLocaleDateString("es-ES", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const tests = useMemo(() => {
|
||||
if (!validatedTests) return [];
|
||||
let list = validatedTests;
|
||||
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.toLowerCase();
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
(t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) ||
|
||||
(t.technique_name && t.technique_name.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
let av = "";
|
||||
let bv = "";
|
||||
switch (sortKey) {
|
||||
case "name":
|
||||
av = a.name.toLowerCase();
|
||||
bv = b.name.toLowerCase();
|
||||
break;
|
||||
case "technique":
|
||||
av = (a.technique_mitre_id || "").toLowerCase();
|
||||
bv = (b.technique_mitre_id || "").toLowerCase();
|
||||
break;
|
||||
case "platform":
|
||||
av = (a.platform || "").toLowerCase();
|
||||
bv = (b.platform || "").toLowerCase();
|
||||
break;
|
||||
case "attack_success":
|
||||
av = String(a.attack_success ?? "");
|
||||
bv = String(b.attack_success ?? "");
|
||||
break;
|
||||
case "detection_result":
|
||||
av = a.detection_result ?? "";
|
||||
bv = b.detection_result ?? "";
|
||||
break;
|
||||
case "created_at":
|
||||
av = a.created_at || "";
|
||||
bv = b.created_at || "";
|
||||
break;
|
||||
case "updated_at":
|
||||
av = a.updated_at || "";
|
||||
bv = b.updated_at || "";
|
||||
break;
|
||||
}
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [validatedTests, searchText, sortKey, sortDir]);
|
||||
|
||||
const SortIcon = ({ col }: { col: SortKey }) =>
|
||||
sortKey === col ? (
|
||||
sortDir === "asc" ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-green-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-green-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||
<p className="text-red-400">Failed to load validated tests</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: { key: SortKey; label: string }[] = [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "technique", label: "Technique" },
|
||||
{ key: "platform", label: "Platform" },
|
||||
{ key: "attack_success", label: "Attack" },
|
||||
{ key: "detection_result", label: "Detection" },
|
||||
{ key: "created_at", label: "Created" },
|
||||
{ key: "updated_at", label: "Validated" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-7 w-7 text-green-400" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Validated Tests</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-400">
|
||||
Tests that have completed the full Red/Blue validation workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-3 py-1 text-sm font-medium text-green-400">
|
||||
{validatedTests?.length ?? 0} validated
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="Search by name or technique…"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-green-900/30 bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
{columns.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
className="px-4 py-3 font-medium text-gray-400 cursor-pointer select-none hover:text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<SortIcon col={key} />
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-medium text-gray-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tests.map((test: Test) => (
|
||||
<tr
|
||||
key={test.id}
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/tests/${test.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-gray-200">{test.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{test.technique_mitre_id ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs text-cyan-400">
|
||||
{test.technique_mitre_id}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[160px]">
|
||||
{test.technique_name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{test.platform || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<AttackBadge success={test.attack_success} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<DetectionBadge result={test.detection_result} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400 whitespace-nowrap">
|
||||
{formatDate(test.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400 whitespace-nowrap">
|
||||
{formatDate(test.updated_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/tests/${test.id}`);
|
||||
}}
|
||||
className="text-sm text-green-400 hover:underline"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{tests.length === 0 && (
|
||||
<div className="py-16 text-center text-gray-500 text-sm">
|
||||
{searchText ? "No validated tests match your search." : "No validated tests yet."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user