Files
Aegis/frontend/src/pages/TestsPage.tsx
kitos 1120d8f2ce
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
feat(tests): add Validated Tests as dedicated page, remove duplicate sidebar entry
- 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>
2026-05-28 17:18:21 +02:00

626 lines
23 KiB
TypeScript

import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Loader2,
AlertCircle,
Plus,
Filter,
ListChecks,
Clock,
CheckCircle,
XCircle,
Eye,
Play,
Shield,
Search,
ChevronUp,
ChevronDown,
ChevronsUpDown,
Timer,
} from "lucide-react";
import { getTests, type TestListFilters } from "../api/tests";
import type { Test, TestState } from "../types/models";
import { useAuth } from "../context/AuthContext";
/* ── Badge colour map ──────────────────────────────────────────────── */
const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
};
const testStateLabels: Record<TestState, string> = {
draft: "Draft",
red_executing: "Red Executing",
blue_evaluating: "Blue Evaluating",
in_review: "In Review",
validated: "Validated",
rejected: "Rejected",
};
const ALL_STATES: TestState[] = [
"draft",
"red_executing",
"blue_evaluating",
"in_review",
"validated",
"rejected",
];
/* ── Helper: which team "owns" the current state ────────────────────── */
function currentTeamForState(state: TestState): string {
switch (state) {
case "draft":
case "red_executing":
return "Red Team";
case "blue_evaluating":
return "Blue Team";
case "in_review":
return "Managers";
case "validated":
return "-";
case "rejected":
return "Red Team";
default:
return "-";
}
}
/* ── 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() {
const navigate = useNavigate();
const { user } = useAuth();
const canCreate =
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
// ── Filter state ──────────────────────────────────────────────────
const [stateFilter, setStateFilter] = useState<TestState | "">("");
const [platformFilter, setPlatformFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [showMyTasks, setShowMyTasks] = useState(false);
// ── Sort state ────────────────────────────────────────────────────
type SortKey =
| "name"
| "technique"
| "state"
| "team"
| "platform"
| "created_at"
| "updated_at"
| "waiting_time";
const [sortKey, setSortKey] = useState<SortKey>("created_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(key === "waiting_time" ? "asc" : "asc");
}
};
// Build API filters
const filters = useMemo<TestListFilters>(() => {
const f: TestListFilters = { limit: 200 };
if (showMyTasks && user) {
switch (user.role) {
case "red_tech":
f.created_by = user.id;
if (stateFilter) f.state = stateFilter as TestState;
break;
case "blue_tech":
f.state = "blue_evaluating";
break;
case "red_lead":
f.pending_validation_side = "red";
break;
case "blue_lead":
f.pending_validation_side = "blue";
break;
default:
if (stateFilter) f.state = stateFilter as TestState;
break;
}
} else {
if (stateFilter) f.state = stateFilter as TestState;
}
if (platformFilter) f.platform = platformFilter;
return f;
}, [stateFilter, platformFilter, showMyTasks, user]);
const {
data: allTests,
isLoading,
error,
} = useQuery({
queryKey: ["tests", filters],
queryFn: () => getTests(filters),
});
// Client-side filtering
const tests = useMemo(() => {
if (!allTests) return [];
let filtered = allTests;
// Red tech "my tasks" — client-side filter for draft + red_executing
if (showMyTasks && user?.role === "red_tech" && !stateFilter) {
filtered = filtered.filter(
(t) => t.state === "draft" || t.state === "red_executing"
);
}
// Exclude validated from this table — they live in /tests/validated
if (stateFilter !== "validated") {
filtered = filtered.filter((t) => t.state !== "validated");
}
// Search text
if (searchText.trim()) {
const q = searchText.toLowerCase();
filtered = filtered.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))
);
}
// Sort
filtered = [...filtered].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 "state":
av = a.state;
bv = b.state;
break;
case "team":
av = currentTeamForState(a.state);
bv = currentTeamForState(b.state);
break;
case "platform":
av = (a.platform || "").toLowerCase();
bv = (b.platform || "").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;
case "waiting_time": {
const aIsBlue = a.state === "blue_evaluating";
const bIsBlue = b.state === "blue_evaluating";
if (aIsBlue && bIsBlue) {
av = a.updated_at || "";
bv = b.updated_at || "";
} else {
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;
});
return filtered;
}, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]);
// ── State counters ────────────────────────────────────────────────
const { data: allTestsUnfiltered } = useQuery({
queryKey: ["tests", "unfiltered-counts"],
queryFn: () => getTests({ limit: 200 }),
});
const globalCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of ALL_STATES) counts[s] = 0;
if (allTestsUnfiltered) {
for (const t of allTestsUnfiltered) {
counts[t.state] = (counts[t.state] || 0) + 1;
}
}
return counts;
}, [allTestsUnfiltered]);
const totalTests = allTestsUnfiltered?.length || 0;
// ── Formatting helpers ─────────────────────────────────────────────
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",
});
};
// ── My tasks label ────────────────────────────────────────────────
const myTasksLabel = useMemo(() => {
if (!user) return "My Tasks";
switch (user.role) {
case "red_tech":
return "My Tests (Draft / Executing)";
case "blue_tech":
return "Pending Blue Evaluation";
case "red_lead":
return "Pending Red Validation";
case "blue_lead":
return "Pending Blue Validation";
default:
return "My Tasks";
}
}, [user]);
// ── Render ─────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-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 tests</p>
</div>
);
}
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Tests</h1>
<p className="mt-1 text-sm text-gray-400">
Security tests for technique validation Red/Blue workflow
</p>
</div>
{canCreate && (
<button
onClick={() => navigate("/tests/new")}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
New Test
</button>
)}
</div>
{/* ── State Counter Cards ───────────────────────────────────────── */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
{ALL_STATES.map((state) => {
const icons: Record<TestState, React.ReactNode> = {
draft: <Clock className="h-5 w-5 text-gray-400" />,
red_executing: <Play className="h-5 w-5 text-orange-400" />,
blue_evaluating: <Shield className="h-5 w-5 text-indigo-400" />,
in_review: <Eye className="h-5 w-5 text-blue-400" />,
validated: <CheckCircle className="h-5 w-5 text-green-400" />,
rejected: <XCircle className="h-5 w-5 text-red-400" />,
};
const colorMap: Record<TestState, string> = {
draft: "text-gray-400",
red_executing: "text-orange-400",
blue_evaluating: "text-indigo-400",
in_review: "text-blue-400",
validated: "text-green-400",
rejected: "text-red-400",
};
return (
<button
key={state}
onClick={() => {
if (state === "validated") {
navigate("/tests/validated");
return;
}
setShowMyTasks(false);
setStateFilter(stateFilter === state ? "" : state);
}}
className={`rounded-xl border p-4 text-left transition-colors ${
stateFilter === state
? "border-cyan-500/50 bg-cyan-500/10"
: "border-gray-800 bg-gray-900 hover:border-gray-700"
}`}
>
<div className="flex items-center gap-2">
{icons[state]}
<span className="text-xs text-gray-400 truncate">
{testStateLabels[state]}
</span>
</div>
<p className={`mt-2 text-2xl font-bold ${colorMap[state]}`}>
{globalCounts[state]}
</p>
</button>
);
})}
</div>
{/* ── Filters Bar ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* My tasks toggle */}
{user?.role !== "admin" && user?.role !== "viewer" && (
<button
onClick={() => {
setShowMyTasks(!showMyTasks);
if (!showMyTasks) setStateFilter("");
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyTasks
? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
}`}
>
<ListChecks className="h-4 w-4" />
{myTasksLabel}
</button>
)}
{/* State filter */}
<div className="flex items-center gap-1.5">
<Filter className="h-4 w-4 text-gray-500" />
<select
value={stateFilter}
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"
>
<option value="">All States</option>
{ALL_STATES.filter((s) => s !== "validated").map((s) => (
<option key={s} value={s}>
{testStateLabels[s]}
</option>
))}
</select>
</div>
{/* Platform filter */}
<input
type="text"
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
placeholder="Platform..."
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none w-32"
/>
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<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-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
{/* Clear filters */}
{(stateFilter || platformFilter || searchText || showMyTasks) && (
<button
onClick={() => {
setStateFilter("");
setPlatformFilter("");
setSearchText("");
setShowMyTasks(false);
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
Clear all
</button>
)}
</div>
{/* Active filter summary */}
{(stateFilter || showMyTasks) && (
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
<span>Showing:</span>
{showMyTasks && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
{myTasksLabel}
</span>
)}
{stateFilter && (
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-gray-300">
{testStateLabels[stateFilter as TestState]}
</span>
)}
<span className="text-gray-500">
({tests.length} of {totalTests} tests)
</span>
</div>
)}
</div>
{/* ── Tests Table ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{showMyTasks ? myTasksLabel : "All Tests"}
</h2>
<span className="text-sm text-gray-400">{tests.length} tests</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
{mainTableColumns.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={() => handleSort(key)}
>
<span className="inline-flex items-center gap-1">
{key === "waiting_time" && (
<Timer className="h-3.5 w-3.5 text-indigo-400" />
)}
{label}
{sortKey === key ? (
sortDir === "asc" ? (
<ChevronUp className="h-3.5 w-3.5 text-cyan-400" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-cyan-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>
{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="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">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
testStateBadgeColors[test.state]
}`}
>
{testStateLabels[test.state]}
</span>
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{currentTeamForState(test.state)}
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{test.platform || "-"}
</td>
{/* Waiting time — meaningful for blue_evaluating */}
<td className="py-3 px-4 text-xs whitespace-nowrap">
{test.state === "blue_evaluating" ? (
<span className="font-mono text-indigo-400">
{formatElapsed(test.updated_at)}
</span>
) : (
<span className="text-gray-700"></span>
)}
</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-cyan-400 hover:underline"
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
{tests.length === 0 && (
<div className="py-12 text-center text-gray-400">
{showMyTasks
? "No pending tasks for your role."
: "No tests found matching your filters."}
</div>
)}
</div>
</div>
</div>
);
}