diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 233b123..29784e3 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -67,12 +67,19 @@ export interface TestValidatePayload { export interface TestListFilters { state?: TestState; technique_id?: string; + /** Free-text filter on technique MITRE ID or name — doesn't require knowing the technique's UUID. */ + technique_search?: string; platform?: string; created_by?: string; pending_validation_side?: "red" | "blue"; /** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */ reviewer_id?: string; not_in_any_campaign?: boolean; + attack_success?: AttackSuccessResult; + detection_result?: TestResult; + /** ISO date/datetime — filters on whichever lead validated last. */ + validated_from?: string; + validated_to?: string; assigned_to_me?: boolean; unassigned_red?: boolean; unassigned_blue?: boolean; @@ -80,18 +87,28 @@ export interface TestListFilters { limit?: number; } -// ── CRUD ─────────────────────────────────────────────────────────── - -/** List tests with optional filters. */ -export async function getTests(filters?: TestListFilters): Promise { +function buildTestListParams(filters?: TestListFilters): URLSearchParams { const params = new URLSearchParams(); if (filters?.state) params.append("state", filters.state); if (filters?.technique_id) params.append("technique_id", filters.technique_id); + if (filters?.technique_search) params.append("technique_search", filters.technique_search); if (filters?.platform) params.append("platform", filters.platform); if (filters?.created_by) params.append("created_by", filters.created_by); if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side); if (filters?.reviewer_id) params.append("reviewer_id", filters.reviewer_id); if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true"); + if (filters?.attack_success) params.append("attack_success", filters.attack_success); + if (filters?.detection_result) params.append("detection_result", filters.detection_result); + if (filters?.validated_from) params.append("validated_from", filters.validated_from); + if (filters?.validated_to) params.append("validated_to", filters.validated_to); + return params; +} + +// ── CRUD ─────────────────────────────────────────────────────────── + +/** List tests with optional filters. */ +export async function getTests(filters?: TestListFilters): Promise { + const params = buildTestListParams(filters); if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); @@ -101,6 +118,15 @@ export async function getTests(filters?: TestListFilters): Promise { return data; } +/** Return the true total of tests matching the given filters (unaffected by pagination limits). */ +export async function getTestsCount(filters?: TestListFilters): Promise { + const params = buildTestListParams(filters); + const { data } = await client.get<{ total: number }>( + `/tests/count${params.toString() ? `?${params}` : ""}`, + ); + return data.total; +} + /** Create a new test. */ export async function createTest(payload: TestCreatePayload): Promise { const { data } = await client.post("/tests", payload); diff --git a/frontend/src/pages/ValidatedTestsPage.tsx b/frontend/src/pages/ValidatedTestsPage.tsx index c687ac5..e03bff8 100644 --- a/frontend/src/pages/ValidatedTestsPage.tsx +++ b/frontend/src/pages/ValidatedTestsPage.tsx @@ -11,8 +11,10 @@ import { ChevronsUpDown, ShieldCheck, Swords, + Filter, + X, } from "lucide-react"; -import { getTests } from "../api/tests"; +import { getTests, getTestsCount, type TestListFilters } from "../api/tests"; import type { Test, TestResult, AttackSuccessResult } from "../types/models"; /* ── Result helpers ─────────────────────────────────────────────────── */ @@ -69,6 +71,43 @@ export default function ValidatedTestsPage() { const [searchText, setSearchText] = useState(""); + // Server-side filters — beyond the client-side name/technique search above, + // which only searches within the current page of fetched results. + const [techniqueSearch, setTechniqueSearch] = useState(""); + const [platformFilter, setPlatformFilter] = useState(""); + const [attackSuccessFilter, setAttackSuccessFilter] = useState(""); + const [detectionResultFilter, setDetectionResultFilter] = useState(""); + const [validatedFrom, setValidatedFrom] = useState(""); + const [validatedTo, setValidatedTo] = useState(""); + + const hasActiveFilters = Boolean( + techniqueSearch || platformFilter || attackSuccessFilter || detectionResultFilter || validatedFrom || validatedTo + ); + + const clearFilters = () => { + setTechniqueSearch(""); + setPlatformFilter(""); + setAttackSuccessFilter(""); + setDetectionResultFilter(""); + setValidatedFrom(""); + setValidatedTo(""); + }; + + const serverFilters: TestListFilters = useMemo( + () => ({ + state: "validated", + ...(techniqueSearch ? { technique_search: techniqueSearch } : {}), + ...(platformFilter ? { platform: platformFilter } : {}), + ...(attackSuccessFilter ? { attack_success: attackSuccessFilter } : {}), + ...(detectionResultFilter ? { detection_result: detectionResultFilter } : {}), + ...(validatedFrom ? { validated_from: validatedFrom } : {}), + // Date-only input parses to midnight — extend to end-of-day so the + // "to" bound includes tests validated any time on that date. + ...(validatedTo ? { validated_to: `${validatedTo}T23:59:59` } : {}), + }), + [techniqueSearch, platformFilter, attackSuccessFilter, detectionResultFilter, validatedFrom, validatedTo] + ); + type SortKey = | "name" | "technique" @@ -91,8 +130,16 @@ export default function ValidatedTestsPage() { }; const { data: validatedTests, isLoading, error } = useQuery({ - queryKey: ["tests", "validated"], - queryFn: () => getTests({ state: "validated", limit: 200 }), + queryKey: ["tests", "validated", serverFilters], + queryFn: () => getTests({ ...serverFilters, limit: 200 }), + }); + + // Independent of the page's `limit` cap — lets the count badge (and the + // "showing X of Y" hint below) reflect the true total even when there + // are more matches than the 200-row page size. + const { data: totalCount } = useQuery({ + queryKey: ["tests", "validated", "count", serverFilters], + queryFn: () => getTestsCount(serverFilters), }); const formatDate = (dateStr: string | null | undefined) => { @@ -211,20 +258,98 @@ export default function ValidatedTestsPage() { - {validatedTests?.length ?? 0} validated + {totalCount ?? validatedTests?.length ?? 0} validated - {/* Search */} -
- - 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" - /> + {/* Search + filters */} +
+
+
+ + 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" + /> +
+ +
+ + setTechniqueSearch(e.target.value)} + placeholder="Technique (T1059…)" + 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" + /> +
+ + setPlatformFilter(e.target.value)} + placeholder="Platform…" + className="w-32 rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:outline-none" + /> + + + + + +
+ Validated + setValidatedFrom(e.target.value)} + className="rounded-lg border border-gray-700 bg-gray-900 px-2 py-2 text-sm text-gray-300 focus:border-green-500 focus:outline-none" + /> + + setValidatedTo(e.target.value)} + className="rounded-lg border border-gray-700 bg-gray-900 px-2 py-2 text-sm text-gray-300 focus:border-green-500 focus:outline-none" + /> +
+ + {hasActiveFilters && ( + + )} +
+ + {totalCount !== undefined && totalCount > tests.length && ( +

+ Showing {tests.length} of {totalCount} matching tests — narrow the filters above to see the rest. +

+ )}
{/* Table */} @@ -305,7 +430,9 @@ export default function ValidatedTestsPage() { {tests.length === 0 && (
- {searchText ? "No validated tests match your search." : "No validated tests yet."} + {searchText || hasActiveFilters + ? "No validated tests match your search/filters." + : "No validated tests yet."}
)}