diff --git a/frontend/src/pages/TestsPage.tsx b/frontend/src/pages/TestsPage.tsx index e1efa55..13e2626 100644 --- a/frontend/src/pages/TestsPage.tsx +++ b/frontend/src/pages/TestsPage.tsx @@ -17,6 +17,7 @@ import { ChevronUp, ChevronDown, ChevronsUpDown, + Timer, } from "lucide-react"; import { getTests, type TestListFilters } from "../api/tests"; import type { Test, TestState } from "../types/models"; @@ -71,6 +72,22 @@ function currentTeamForState(state: TestState): string { } } +/* ── Helper: elapsed time since a date ─────────────────────────────── */ + +function formatElapsed(dateStr: string | null | undefined): string { + if (!dateStr) return "—"; + const utc = + dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z"; + const ms = Date.now() - new Date(utc).getTime(); + const minutes = Math.floor(ms / 60000); + if (minutes < 1) return "<1m"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; +} + /* ── Component ──────────────────────────────────────────────────────── */ export default function TestsPage() { @@ -86,8 +103,19 @@ export default function TestsPage() { const [searchText, setSearchText] = useState(""); const [showMyTasks, setShowMyTasks] = useState(false); + // ── Validated section toggle ────────────────────────────────────── + const [showValidated, setShowValidated] = useState(false); + // ── Sort state ──────────────────────────────────────────────────── - type SortKey = "name" | "technique" | "state" | "team" | "platform" | "created_at" | "updated_at"; + type SortKey = + | "name" + | "technique" + | "state" + | "team" + | "platform" + | "created_at" + | "updated_at" + | "waiting_time"; const [sortKey, setSortKey] = useState("created_at"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); @@ -96,7 +124,8 @@ export default function TestsPage() { setSortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setSortKey(key); - setSortDir("asc"); + // For waiting_time, default to asc (oldest = most urgent first) + setSortDir(key === "waiting_time" ? "asc" : "asc"); } }; @@ -105,16 +134,10 @@ export default function TestsPage() { const f: TestListFilters = { limit: 200 }; if (showMyTasks && user) { - // Role-specific "my tasks" filtering switch (user.role) { case "red_tech": - // Tests I created in draft or red_executing f.created_by = user.id; - if (!stateFilter) { - // Client-side filter for draft + red_executing - } else { - f.state = stateFilter as TestState; - } + if (stateFilter) f.state = stateFilter as TestState; break; case "blue_tech": f.state = "blue_evaluating"; @@ -126,7 +149,6 @@ export default function TestsPage() { f.pending_validation_side = "blue"; break; default: - // admin: show all if (stateFilter) f.state = stateFilter as TestState; break; } @@ -147,7 +169,13 @@ export default function TestsPage() { queryFn: () => getTests(filters), }); - // Client-side filtering for search text and "my tasks" for red_tech + // 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 const tests = useMemo(() => { if (!allTests) return []; let filtered = allTests; @@ -159,6 +187,12 @@ export default function TestsPage() { ); } + // Exclude validated from the main active-tests table + // (unless the user explicitly chose to filter by validated) + if (stateFilter !== "validated") { + filtered = filtered.filter((t) => t.state !== "validated"); + } + // Search text if (searchText.trim()) { const q = searchText.toLowerCase(); @@ -172,8 +206,9 @@ export default function TestsPage() { // Sort filtered = [...filtered].sort((a, b) => { - let av: string = ""; - let bv: string = ""; + let av = ""; + let bv = ""; + switch (sortKey) { case "name": av = a.name.toLowerCase(); @@ -203,6 +238,22 @@ export default function TestsPage() { av = a.updated_at || ""; bv = b.updated_at || ""; break; + case "waiting_time": { + // Both blue_evaluating: oldest updated_at = longest wait + const aIsBlue = a.state === "blue_evaluating"; + const bIsBlue = b.state === "blue_evaluating"; + if (aIsBlue && bIsBlue) { + av = a.updated_at || ""; + bv = b.updated_at || ""; + } 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; + av = a.updated_at || ""; + bv = b.updated_at || ""; + } + break; + } } const cmp = av < bv ? -1 : av > bv ? 1 : 0; return sortDir === "asc" ? cmp : -cmp; @@ -212,7 +263,6 @@ export default function TestsPage() { }, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]); // ── State counters ──────────────────────────────────────────────── - // Count from allTests (before client search filter) to show accurate pipeline const stateCounts = useMemo(() => { const counts: Record = {}; for (const s of ALL_STATES) counts[s] = 0; @@ -225,9 +275,7 @@ export default function TestsPage() { }, [allTests]); // Count from unfiltered query for the top cards - const { - data: allTestsUnfiltered, - } = useQuery({ + const { data: allTestsUnfiltered } = useQuery({ queryKey: ["tests", "unfiltered-counts"], queryFn: () => getTests({ limit: 200 }), }); @@ -248,7 +296,6 @@ export default function TestsPage() { // ── Formatting helpers ───────────────────────────────────────────── const formatDate = (dateStr: string | null | undefined) => { if (!dateStr) return "-"; - // Backend returns naive UTC; append Z so JS treats it as UTC const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z"; return new Date(utc).toLocaleDateString("es-ES", { year: "numeric", @@ -274,6 +321,35 @@ export default function TestsPage() { } }, [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 ───────────────────────────────────────────────────────── if (isLoading) { @@ -293,6 +369,17 @@ export default function TestsPage() { ); } + const mainTableColumns: { key: SortKey; label: string; cls: string }[] = [ + { key: "name", label: "Name", cls: "pr-4" }, + { key: "technique", label: "Technique", cls: "px-4" }, + { key: "state", label: "State", cls: "px-4" }, + { key: "team", label: "Current Team", cls: "px-4" }, + { key: "platform", label: "Platform", cls: "px-4" }, + { key: "waiting_time", label: "Waiting", cls: "px-4" }, + { key: "created_at", label: "Created", cls: "px-4" }, + { key: "updated_at", label: "Updated", cls: "px-4" }, + ]; + return (
{/* Header */} @@ -456,12 +543,19 @@ export default function TestsPage() { )}
- {/* ── Tests Table ───────────────────────────────────────────────── */} + {/* ── Active Tests Table ────────────────────────────────────────── */}
-

- {showMyTasks ? myTasksLabel : "All Tests"} -

+
+

+ {showMyTasks ? myTasksLabel : "All Tests"} +

+ {stateFilter !== "validated" && ( + + validated shown below + + )} +
{tests.length} tests
@@ -469,23 +563,16 @@ export default function TestsPage() { - {( - [ - { key: "name", label: "Name", cls: "pr-4" }, - { key: "technique", label: "Technique", cls: "px-4" }, - { key: "state", label: "State", cls: "px-4" }, - { key: "team", label: "Current Team", cls: "px-4" }, - { key: "platform", label: "Platform", cls: "px-4" }, - { key: "created_at", label: "Created", cls: "px-4" }, - { key: "updated_at", label: "Updated", cls: "px-4" }, - ] as { key: SortKey; label: string; cls: string }[] - ).map(({ key, label, cls }) => ( + {mainTableColumns.map(({ key, label, cls }) => ( + {/* Waiting time — relevant for blue_evaluating */} + @@ -572,6 +669,123 @@ export default function TestsPage() { )} + + {/* ── Validated / Approved Tests ────────────────────────────────── */} +
+ {/* Collapsible header */} + + + {showValidated && ( +
+
+
handleSort(key)} > + {key === "waiting_time" && ( + + )} {label} {sortKey === key ? ( sortDir === "asc" ? ( @@ -541,6 +628,16 @@ export default function TestsPage() { {test.platform || "-"} + {test.state === "blue_evaluating" ? ( + + {formatElapsed(test.updated_at)} + + ) : ( + + )} + {formatDate(test.created_at)}
+ + + {( + [ + { 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 }) => ( + + ))} + + + + + {sortedValidatedTests.map((test: Test) => ( + navigate(`/tests/${test.id}`)} + > + + + + + + + + ))} + +
handleValSort(key)} + > + + {label} + {valSortKey === key ? ( + valSortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + Action
+ {test.name} + + {test.technique_mitre_id ? ( +
+ + {test.technique_mitre_id} + + + {test.technique_name} + +
+ ) : ( + - + )} +
+ {test.platform || "-"} + + {formatDate(test.created_at)} + + {formatDate(test.updated_at)} + + +
+ + {sortedValidatedTests.length === 0 && ( +
+ No validated tests yet. +
+ )} +
+ + )} + ); }