feat(frontend): add technique/platform/outcome/date filters to Validated Tests
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
The "200" badge on the Validated Tests page was a hardcoded query limit, not the real total — silently hiding results beyond the 200th once there were more validated tests than that. Now shows the true total via the new /tests/count endpoint, and warns when the fetched page doesn't cover all matches. Adds filter controls beyond the existing name/technique text search: technique (MITRE ID or name), platform, attack outcome, detection outcome, and a validated-date range — all pushed down to the server via the new GET /tests filters instead of only ever searching the current page client-side.
This commit is contained in:
@@ -67,12 +67,19 @@ export interface TestValidatePayload {
|
|||||||
export interface TestListFilters {
|
export interface TestListFilters {
|
||||||
state?: TestState;
|
state?: TestState;
|
||||||
technique_id?: string;
|
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;
|
platform?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
pending_validation_side?: "red" | "blue";
|
pending_validation_side?: "red" | "blue";
|
||||||
/** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */
|
/** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */
|
||||||
reviewer_id?: string;
|
reviewer_id?: string;
|
||||||
not_in_any_campaign?: boolean;
|
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;
|
assigned_to_me?: boolean;
|
||||||
unassigned_red?: boolean;
|
unassigned_red?: boolean;
|
||||||
unassigned_blue?: boolean;
|
unassigned_blue?: boolean;
|
||||||
@@ -80,18 +87,28 @@ export interface TestListFilters {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CRUD ───────────────────────────────────────────────────────────
|
function buildTestListParams(filters?: TestListFilters): URLSearchParams {
|
||||||
|
|
||||||
/** List tests with optional filters. */
|
|
||||||
export async function getTests(filters?: TestListFilters): Promise<Test[]> {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.state) params.append("state", filters.state);
|
if (filters?.state) params.append("state", filters.state);
|
||||||
if (filters?.technique_id) params.append("technique_id", filters.technique_id);
|
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?.platform) params.append("platform", filters.platform);
|
||||||
if (filters?.created_by) params.append("created_by", filters.created_by);
|
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?.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?.reviewer_id) params.append("reviewer_id", filters.reviewer_id);
|
||||||
if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true");
|
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<Test[]> {
|
||||||
|
const params = buildTestListParams(filters);
|
||||||
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
||||||
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
||||||
|
|
||||||
@@ -101,6 +118,15 @@ export async function getTests(filters?: TestListFilters): Promise<Test[]> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the true total of tests matching the given filters (unaffected by pagination limits). */
|
||||||
|
export async function getTestsCount(filters?: TestListFilters): Promise<number> {
|
||||||
|
const params = buildTestListParams(filters);
|
||||||
|
const { data } = await client.get<{ total: number }>(
|
||||||
|
`/tests/count${params.toString() ? `?${params}` : ""}`,
|
||||||
|
);
|
||||||
|
return data.total;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new test. */
|
/** Create a new test. */
|
||||||
export async function createTest(payload: TestCreatePayload): Promise<Test> {
|
export async function createTest(payload: TestCreatePayload): Promise<Test> {
|
||||||
const { data } = await client.post<Test>("/tests", payload);
|
const { data } = await client.post<Test>("/tests", payload);
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Swords,
|
Swords,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTests } from "../api/tests";
|
import { getTests, getTestsCount, type TestListFilters } from "../api/tests";
|
||||||
import type { Test, TestResult, AttackSuccessResult } from "../types/models";
|
import type { Test, TestResult, AttackSuccessResult } from "../types/models";
|
||||||
|
|
||||||
/* ── Result helpers ─────────────────────────────────────────────────── */
|
/* ── Result helpers ─────────────────────────────────────────────────── */
|
||||||
@@ -69,6 +71,43 @@ export default function ValidatedTestsPage() {
|
|||||||
|
|
||||||
const [searchText, setSearchText] = useState("");
|
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<AttackSuccessResult | "">("");
|
||||||
|
const [detectionResultFilter, setDetectionResultFilter] = useState<TestResult | "">("");
|
||||||
|
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 =
|
type SortKey =
|
||||||
| "name"
|
| "name"
|
||||||
| "technique"
|
| "technique"
|
||||||
@@ -91,8 +130,16 @@ export default function ValidatedTestsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data: validatedTests, isLoading, error } = useQuery({
|
const { data: validatedTests, isLoading, error } = useQuery({
|
||||||
queryKey: ["tests", "validated"],
|
queryKey: ["tests", "validated", serverFilters],
|
||||||
queryFn: () => getTests({ state: "validated", limit: 200 }),
|
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) => {
|
const formatDate = (dateStr: string | null | undefined) => {
|
||||||
@@ -211,20 +258,98 @@ export default function ValidatedTestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
{totalCount ?? validatedTests?.length ?? 0} validated
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search + filters */}
|
||||||
<div className="relative max-w-sm">
|
<div className="space-y-3">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<input
|
<div className="relative max-w-sm flex-1 min-w-[200px]">
|
||||||
type="text"
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
value={searchText}
|
<input
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
type="text"
|
||||||
placeholder="Search by name or technique…"
|
value={searchText}
|
||||||
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"
|
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>
|
||||||
|
|
||||||
|
<div className="relative w-40">
|
||||||
|
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={techniqueSearch}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={platformFilter}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={attackSuccessFilter}
|
||||||
|
onChange={(e) => setAttackSuccessFilter(e.target.value as AttackSuccessResult | "")}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-300 focus:border-green-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Any attack outcome</option>
|
||||||
|
<option value="successful">Success</option>
|
||||||
|
<option value="partially_successful">Partial</option>
|
||||||
|
<option value="not_successful">Blocked</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={detectionResultFilter}
|
||||||
|
onChange={(e) => setDetectionResultFilter(e.target.value as TestResult | "")}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-300 focus:border-green-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Any detection</option>
|
||||||
|
<option value="detected">Detected</option>
|
||||||
|
<option value="partially_detected">Partial</option>
|
||||||
|
<option value="not_detected">Not Detected</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<span>Validated</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={validatedFrom}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span>–</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={validatedTo}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalCount !== undefined && totalCount > tests.length && (
|
||||||
|
<p className="text-xs text-amber-400/80">
|
||||||
|
Showing {tests.length} of {totalCount} matching tests — narrow the filters above to see the rest.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@@ -305,7 +430,9 @@ export default function ValidatedTestsPage() {
|
|||||||
|
|
||||||
{tests.length === 0 && (
|
{tests.length === 0 && (
|
||||||
<div className="py-16 text-center text-gray-500 text-sm">
|
<div className="py-16 text-center text-gray-500 text-sm">
|
||||||
{searchText ? "No validated tests match your search." : "No validated tests yet."}
|
{searchText || hasActiveFilters
|
||||||
|
? "No validated tests match your search/filters."
|
||||||
|
: "No validated tests yet."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user