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:
@@ -18,6 +18,7 @@ const TestsPage = React.lazy(() => import("./pages/TestsPage"));
|
|||||||
const TestCreatePage = React.lazy(() => import("./pages/TestCreatePage"));
|
const TestCreatePage = React.lazy(() => import("./pages/TestCreatePage"));
|
||||||
const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage"));
|
const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage"));
|
||||||
const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
|
const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
|
||||||
|
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
|
||||||
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
||||||
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
|
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
|
||||||
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
|
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
|
||||||
@@ -65,6 +66,7 @@ export default function App() {
|
|||||||
{/* ── Tests ────────────────────────────────────────────── */}
|
{/* ── Tests ────────────────────────────────────────────── */}
|
||||||
<Route path="/tests" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestsPage /></Suspense>} />
|
<Route path="/tests" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestsPage /></Suspense>} />
|
||||||
<Route path="/tests/new" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCreatePage /></Suspense>} />
|
<Route path="/tests/new" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCreatePage /></Suspense>} />
|
||||||
|
<Route path="/tests/validated" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ValidatedTestsPage /></Suspense>} />
|
||||||
<Route path="/tests/:testId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestDetailPage /></Suspense>} />
|
<Route path="/tests/:testId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestDetailPage /></Suspense>} />
|
||||||
<Route path="/test-catalog" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />
|
<Route path="/test-catalog" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />
|
||||||
<Route path="/test-catalog/:templateId/use" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />
|
<Route path="/test-catalog/:templateId/use" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ClipboardList,
|
CheckCircle,
|
||||||
Database,
|
Database,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -41,7 +41,7 @@ const mainLinks: NavItem[] = [
|
|||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
children: [
|
children: [
|
||||||
{ to: "/tests", label: "All Tests", icon: ListChecks },
|
{ to: "/tests", label: "All Tests", icon: ListChecks },
|
||||||
{ to: "/tests?view=pending", label: "My Pending Tasks", icon: ClipboardList },
|
{ to: "/tests/validated", label: "Validated Tests", icon: CheckCircle },
|
||||||
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen },
|
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,9 +103,6 @@ export default function TestsPage() {
|
|||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [showMyTasks, setShowMyTasks] = useState(false);
|
const [showMyTasks, setShowMyTasks] = useState(false);
|
||||||
|
|
||||||
// ── Validated section toggle ──────────────────────────────────────
|
|
||||||
const [showValidated, setShowValidated] = useState(false);
|
|
||||||
|
|
||||||
// ── Sort state ────────────────────────────────────────────────────
|
// ── Sort state ────────────────────────────────────────────────────
|
||||||
type SortKey =
|
type SortKey =
|
||||||
| "name"
|
| "name"
|
||||||
@@ -124,7 +121,6 @@ export default function TestsPage() {
|
|||||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
} else {
|
} else {
|
||||||
setSortKey(key);
|
setSortKey(key);
|
||||||
// For waiting_time, default to asc (oldest = most urgent first)
|
|
||||||
setSortDir(key === "waiting_time" ? "asc" : "asc");
|
setSortDir(key === "waiting_time" ? "asc" : "asc");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -169,12 +165,6 @@ export default function TestsPage() {
|
|||||||
queryFn: () => getTests(filters),
|
queryFn: () => getTests(filters),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dedicated query for validated tests (shown in separate section)
|
|
||||||
const { data: validatedTests } = useQuery({
|
|
||||||
queryKey: ["tests", "validated"],
|
|
||||||
queryFn: () => getTests({ state: "validated", limit: 200 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client-side filtering
|
// Client-side filtering
|
||||||
const tests = useMemo(() => {
|
const tests = useMemo(() => {
|
||||||
if (!allTests) return [];
|
if (!allTests) return [];
|
||||||
@@ -187,8 +177,7 @@ export default function TestsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude validated from the main active-tests table
|
// Exclude validated from this table — they live in /tests/validated
|
||||||
// (unless the user explicitly chose to filter by validated)
|
|
||||||
if (stateFilter !== "validated") {
|
if (stateFilter !== "validated") {
|
||||||
filtered = filtered.filter((t) => t.state !== "validated");
|
filtered = filtered.filter((t) => t.state !== "validated");
|
||||||
}
|
}
|
||||||
@@ -239,14 +228,12 @@ export default function TestsPage() {
|
|||||||
bv = b.updated_at || "";
|
bv = b.updated_at || "";
|
||||||
break;
|
break;
|
||||||
case "waiting_time": {
|
case "waiting_time": {
|
||||||
// Both blue_evaluating: oldest updated_at = longest wait
|
|
||||||
const aIsBlue = a.state === "blue_evaluating";
|
const aIsBlue = a.state === "blue_evaluating";
|
||||||
const bIsBlue = b.state === "blue_evaluating";
|
const bIsBlue = b.state === "blue_evaluating";
|
||||||
if (aIsBlue && bIsBlue) {
|
if (aIsBlue && bIsBlue) {
|
||||||
av = a.updated_at || "";
|
av = a.updated_at || "";
|
||||||
bv = b.updated_at || "";
|
bv = b.updated_at || "";
|
||||||
} else {
|
} else {
|
||||||
// Blue_evaluating entries come first when sorting by wait
|
|
||||||
if (aIsBlue && !bIsBlue) return sortDir === "asc" ? -1 : 1;
|
if (aIsBlue && !bIsBlue) return sortDir === "asc" ? -1 : 1;
|
||||||
if (!aIsBlue && bIsBlue) return sortDir === "asc" ? 1 : -1;
|
if (!aIsBlue && bIsBlue) return sortDir === "asc" ? 1 : -1;
|
||||||
av = a.updated_at || "";
|
av = a.updated_at || "";
|
||||||
@@ -263,18 +250,6 @@ export default function TestsPage() {
|
|||||||
}, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]);
|
}, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]);
|
||||||
|
|
||||||
// ── State counters ────────────────────────────────────────────────
|
// ── State counters ────────────────────────────────────────────────
|
||||||
const stateCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const s of ALL_STATES) counts[s] = 0;
|
|
||||||
if (allTests) {
|
|
||||||
for (const t of allTests) {
|
|
||||||
counts[t.state] = (counts[t.state] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}, [allTests]);
|
|
||||||
|
|
||||||
// Count from unfiltered query for the top cards
|
|
||||||
const { data: allTestsUnfiltered } = useQuery({
|
const { data: allTestsUnfiltered } = useQuery({
|
||||||
queryKey: ["tests", "unfiltered-counts"],
|
queryKey: ["tests", "unfiltered-counts"],
|
||||||
queryFn: () => getTests({ limit: 200 }),
|
queryFn: () => getTests({ limit: 200 }),
|
||||||
@@ -296,7 +271,8 @@ export default function TestsPage() {
|
|||||||
// ── Formatting helpers ─────────────────────────────────────────────
|
// ── Formatting helpers ─────────────────────────────────────────────
|
||||||
const formatDate = (dateStr: string | null | undefined) => {
|
const formatDate = (dateStr: string | null | undefined) => {
|
||||||
if (!dateStr) return "-";
|
if (!dateStr) return "-";
|
||||||
const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
const utc =
|
||||||
|
dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
||||||
return new Date(utc).toLocaleDateString("es-ES", {
|
return new Date(utc).toLocaleDateString("es-ES", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -321,35 +297,6 @@ export default function TestsPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// ── Validated sort helpers ─────────────────────────────────────────
|
|
||||||
const [valSortKey, setValSortKey] = useState<"name" | "technique" | "created_at" | "updated_at">("updated_at");
|
|
||||||
const [valSortDir, setValSortDir] = useState<"asc" | "desc">("desc");
|
|
||||||
|
|
||||||
const sortedValidatedTests = useMemo(() => {
|
|
||||||
if (!validatedTests) return [];
|
|
||||||
return [...validatedTests].sort((a, b) => {
|
|
||||||
let av = "";
|
|
||||||
let bv = "";
|
|
||||||
switch (valSortKey) {
|
|
||||||
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 "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 valSortDir === "asc" ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
}, [validatedTests, valSortKey, valSortDir]);
|
|
||||||
|
|
||||||
const handleValSort = (key: typeof valSortKey) => {
|
|
||||||
if (valSortKey === key) {
|
|
||||||
setValSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
||||||
} else {
|
|
||||||
setValSortKey(key);
|
|
||||||
setValSortDir("desc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -424,6 +371,10 @@ export default function TestsPage() {
|
|||||||
<button
|
<button
|
||||||
key={state}
|
key={state}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (state === "validated") {
|
||||||
|
navigate("/tests/validated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowMyTasks(false);
|
setShowMyTasks(false);
|
||||||
setStateFilter(stateFilter === state ? "" : state);
|
setStateFilter(stateFilter === state ? "" : state);
|
||||||
}}
|
}}
|
||||||
@@ -473,11 +424,18 @@ export default function TestsPage() {
|
|||||||
<Filter className="h-4 w-4 text-gray-500" />
|
<Filter className="h-4 w-4 text-gray-500" />
|
||||||
<select
|
<select
|
||||||
value={stateFilter}
|
value={stateFilter}
|
||||||
onChange={(e) => setStateFilter(e.target.value as TestState | "")}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value as TestState | "";
|
||||||
|
if (val === "validated") {
|
||||||
|
navigate("/tests/validated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStateFilter(val);
|
||||||
|
}}
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All States</option>
|
<option value="">All States</option>
|
||||||
{ALL_STATES.map((s) => (
|
{ALL_STATES.filter((s) => s !== "validated").map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{testStateLabels[s]}
|
{testStateLabels[s]}
|
||||||
</option>
|
</option>
|
||||||
@@ -543,19 +501,12 @@ export default function TestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Active Tests Table ────────────────────────────────────────── */}
|
{/* ── Tests Table ───────────────────────────────────────────────── */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
{showMyTasks ? myTasksLabel : "All Tests"}
|
{showMyTasks ? myTasksLabel : "All Tests"}
|
||||||
</h2>
|
</h2>
|
||||||
{stateFilter !== "validated" && (
|
|
||||||
<span className="rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
|
||||||
validated shown below
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -628,7 +579,7 @@ export default function TestsPage() {
|
|||||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||||
{test.platform || "-"}
|
{test.platform || "-"}
|
||||||
</td>
|
</td>
|
||||||
{/* Waiting time — relevant for blue_evaluating */}
|
{/* Waiting time — meaningful for blue_evaluating */}
|
||||||
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
||||||
{test.state === "blue_evaluating" ? (
|
{test.state === "blue_evaluating" ? (
|
||||||
<span className="font-mono text-indigo-400">
|
<span className="font-mono text-indigo-400">
|
||||||
@@ -669,123 +620,6 @@ export default function TestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Validated / Approved Tests ────────────────────────────────── */}
|
|
||||||
<div className="rounded-xl border border-green-900/30 bg-gray-900">
|
|
||||||
{/* Collapsible header */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowValidated((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-3 px-6 py-4 text-left hover:bg-green-500/5 transition-colors rounded-xl"
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-5 w-5 shrink-0 text-green-400" />
|
|
||||||
<h2 className="text-lg font-semibold text-white flex-1">
|
|
||||||
Validated Tests
|
|
||||||
</h2>
|
|
||||||
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2.5 py-0.5 text-xs font-medium text-green-400">
|
|
||||||
{validatedTests?.length ?? 0}
|
|
||||||
</span>
|
|
||||||
{showValidated ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showValidated && (
|
|
||||||
<div className="border-t border-green-900/30 px-6 pb-6 pt-4">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ key: "name" as const, label: "Name", cls: "pr-4" },
|
|
||||||
{ key: "technique" as const, label: "Technique", cls: "px-4" },
|
|
||||||
{ key: "platform", label: "Platform", cls: "px-4" },
|
|
||||||
{ key: "created_at" as const, label: "Created", cls: "px-4" },
|
|
||||||
{ key: "updated_at" as const, label: "Validated", cls: "px-4" },
|
|
||||||
] as { key: typeof valSortKey; label: string; cls: string }[]
|
|
||||||
).map(({ key, label, cls }) => (
|
|
||||||
<th
|
|
||||||
key={key}
|
|
||||||
className={`pb-3 ${cls} font-medium text-gray-400 cursor-pointer select-none hover:text-white transition-colors`}
|
|
||||||
onClick={() => handleValSort(key)}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
{label}
|
|
||||||
{valSortKey === key ? (
|
|
||||||
valSortDir === "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" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedValidatedTests.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="py-3 pr-4">
|
|
||||||
<span className="font-medium text-gray-200">{test.name}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
{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="py-3 px-4 text-gray-400 text-xs">
|
|
||||||
{test.platform || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
|
||||||
{formatDate(test.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
|
||||||
{formatDate(test.updated_at)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pl-4">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/tests/${test.id}`);
|
|
||||||
}}
|
|
||||||
className="text-sm text-green-400 hover:underline"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{sortedValidatedTests.length === 0 && (
|
|
||||||
<div className="py-10 text-center text-gray-500 text-sm">
|
|
||||||
No validated tests yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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