feat(tests): separate validated tests section + waiting time column
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Validated tests no longer appear in the active tests table; they are shown in a dedicated collapsible "Validated Tests" section at the bottom (with its own sortable table and count badge). - Added "Waiting" column to the main table showing elapsed time since last update for blue_evaluating tests, sortable so Blue Team can prioritise the oldest pending evaluations. - Sorting by Waiting pushes blue_evaluating rows to the top and orders them oldest-first by default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SortKey>("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<string, number> = {};
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -456,12 +543,19 @@ export default function TestsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tests Table ───────────────────────────────────────────────── */}
|
||||
{/* ── Active 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{showMyTasks ? myTasksLabel : "All Tests"}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -469,23 +563,16 @@ export default function TestsPage() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
{(
|
||||
[
|
||||
{ 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 }) => (
|
||||
<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" ? (
|
||||
@@ -541,6 +628,16 @@ export default function TestsPage() {
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
{test.platform || "-"}
|
||||
</td>
|
||||
{/* Waiting time — relevant 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>
|
||||
@@ -572,6 +669,123 @@ export default function TestsPage() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user