32f4fd25bd
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
Cross-validation (in_review) tests awaiting a lead's vote were only reachable via the 'My Tasks' toggle, so the default review queue view never showed them despite being exactly the kind of work a lead expects to find there. Added an 'Awaiting My Validation' section to the review queue, and a standalone 'Disputed' section — always fetched independently of other filters, shown above everything else, and only rendered when non-empty — since disputed tests need the most urgent attention.
1045 lines
42 KiB
TypeScript
1045 lines
42 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useQuery, useMutation, useQueryClient } 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,
|
|
AlertTriangle,
|
|
Lightbulb,
|
|
} from "lucide-react";
|
|
import { getTests, type TestListFilters } from "../api/tests";
|
|
import {
|
|
getProcedureSuggestions,
|
|
approveProcedureSuggestion,
|
|
rejectProcedureSuggestion,
|
|
} from "../api/procedure-suggestions";
|
|
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",
|
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-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",
|
|
disputed: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
|
};
|
|
|
|
const testStateLabels: Record<TestState, string> = {
|
|
draft: "Draft",
|
|
red_executing: "Red Executing",
|
|
red_review: "Red Review",
|
|
blue_evaluating: "Blue Evaluating",
|
|
blue_review: "Blue Review",
|
|
in_review: "In Review",
|
|
validated: "Validated",
|
|
rejected: "Rejected",
|
|
disputed: "Disputed",
|
|
};
|
|
|
|
const ALL_STATES: TestState[] = [
|
|
"draft",
|
|
"red_executing",
|
|
"red_review",
|
|
"blue_evaluating",
|
|
"blue_review",
|
|
"in_review",
|
|
"validated",
|
|
"rejected",
|
|
"disputed",
|
|
];
|
|
|
|
/* ── Helper: which team "owns" the current state ────────────────────── */
|
|
|
|
function currentTeamForState(state: TestState): string {
|
|
switch (state) {
|
|
case "draft":
|
|
case "red_executing":
|
|
return "Red Team";
|
|
case "red_review":
|
|
return "Red Lead";
|
|
case "blue_evaluating":
|
|
return "Blue Team";
|
|
case "blue_review":
|
|
return "Blue Lead";
|
|
case "in_review":
|
|
return "Managers";
|
|
case "validated":
|
|
return "-";
|
|
case "rejected":
|
|
return "Red Team";
|
|
default:
|
|
return "-";
|
|
}
|
|
}
|
|
|
|
/* ── Helper: most recent activity timestamp on a test ──────────────── */
|
|
// updated_at column does not exist in the DB — derive from available fields
|
|
function lastActivityDate(t: Test): string | null {
|
|
const candidates = [
|
|
t.blue_validated_at,
|
|
t.red_validated_at,
|
|
(t as any).blue_work_started_at,
|
|
(t as any).blue_started_at,
|
|
(t as any).red_started_at,
|
|
t.created_at,
|
|
].filter(Boolean) as string[];
|
|
if (!candidates.length) return null;
|
|
return candidates.reduce((latest, d) =>
|
|
new Date(d) > new Date(latest) ? d : latest
|
|
);
|
|
}
|
|
|
|
/* ── 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`;
|
|
}
|
|
|
|
/* ── Shared sort key type ────────────────────────────────────────────── */
|
|
|
|
type SortKey =
|
|
| "name"
|
|
| "technique"
|
|
| "state"
|
|
| "team"
|
|
| "platform"
|
|
| "created_at"
|
|
| "updated_at"
|
|
| "waiting_time";
|
|
|
|
/* ── Component ──────────────────────────────────────────────────────── */
|
|
|
|
const isTechRole = (role?: string) => role === "red_tech" || role === "blue_tech";
|
|
|
|
export default function TestsPage() {
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
|
|
// Only leads can create tests — admin administers the site, not test content.
|
|
const canCreate = user?.role === "red_lead" || user?.role === "blue_lead";
|
|
|
|
const techRole = isTechRole(user?.role);
|
|
|
|
// ── Filter state ──────────────────────────────────────────────────
|
|
const [stateFilter, setStateFilter] = useState<TestState | "">("");
|
|
const [platformFilter, setPlatformFilter] = useState("");
|
|
const [searchText, setSearchText] = useState("");
|
|
const [showMyTasks, setShowMyTasks] = useState(false);
|
|
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
|
|
const reviewQueueActive = isReviewLead && !showMyTasks;
|
|
|
|
// ── Sort state ────────────────────────────────────────────────────
|
|
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 = lastActivityDate(a) || "";
|
|
bv = lastActivityDate(b) || "";
|
|
break;
|
|
case "waiting_time": {
|
|
const aIsBlue = a.state === "blue_evaluating";
|
|
const bIsBlue = b.state === "blue_evaluating";
|
|
if (aIsBlue && bIsBlue) {
|
|
// Older blue_started_at = waited longer = sort first in asc
|
|
av = (a as any).blue_started_at || a.created_at || "";
|
|
bv = (b as any).blue_started_at || b.created_at || "";
|
|
} else {
|
|
if (aIsBlue && !bIsBlue) return sortDir === "asc" ? -1 : 1;
|
|
if (!aIsBlue && bIsBlue) return sortDir === "asc" ? 1 : -1;
|
|
av = (a as any).blue_started_at || a.created_at || "";
|
|
bv = (b as any).blue_started_at || b.created_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;
|
|
|
|
// ── Two-queue split for tech roles ────────────────────────────────
|
|
const { availableTests, myAssignedTests } = useMemo(() => {
|
|
if (!techRole || !user || !allTests) {
|
|
return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests };
|
|
}
|
|
|
|
let searchFiltered = allTests.filter((t) => t.state !== "validated");
|
|
if (searchText.trim()) {
|
|
const q = searchText.toLowerCase();
|
|
searchFiltered = searchFiltered.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))
|
|
);
|
|
}
|
|
if (platformFilter) {
|
|
searchFiltered = searchFiltered.filter((t) =>
|
|
t.platform?.toLowerCase().includes(platformFilter.toLowerCase())
|
|
);
|
|
}
|
|
|
|
if (user.role === "red_tech") {
|
|
const available = searchFiltered.filter(
|
|
(t) =>
|
|
(t.state === "draft" || t.state === "red_executing") &&
|
|
t.red_tech_assignee === null
|
|
);
|
|
const mine = searchFiltered.filter((t) => t.red_tech_assignee === user.id);
|
|
return { availableTests: available, myAssignedTests: mine };
|
|
}
|
|
|
|
if (user.role === "blue_tech") {
|
|
const available = searchFiltered.filter(
|
|
(t) =>
|
|
t.state === "blue_evaluating" &&
|
|
t.blue_tech_assignee === null &&
|
|
t.blue_work_started_at === null
|
|
);
|
|
const mine = searchFiltered.filter((t) => t.blue_tech_assignee === user.id);
|
|
return { availableTests: available, myAssignedTests: mine };
|
|
}
|
|
|
|
return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests };
|
|
}, [techRole, user, allTests, searchText, platformFilter]);
|
|
|
|
// ── Two-queue split for lead review gate (red_review / blue_review) ─
|
|
// The auto load-balancer always assigns a reviewer immediately, so
|
|
// "available" here means "assigned to another lead" — visible so a
|
|
// lead can pick up a peer's review if they can't get to it.
|
|
//
|
|
// Also includes cross-validation (in_review) tests still awaiting this
|
|
// lead's vote — previously only surfaced by toggling "My Tasks", which
|
|
// meant a lead landing on this page by default never saw them here at
|
|
// all despite this being "the review queue" as far as they're concerned.
|
|
const { availableReviews, myAssignedReviews, pendingMyValidation } = useMemo(() => {
|
|
if (!isReviewLead || !user || !allTests || showMyTasks) {
|
|
return {
|
|
availableReviews: [] as typeof tests,
|
|
myAssignedReviews: [] as typeof tests,
|
|
pendingMyValidation: [] as typeof tests,
|
|
};
|
|
}
|
|
|
|
let searchFiltered = allTests;
|
|
if (searchText.trim()) {
|
|
const q = searchText.toLowerCase();
|
|
searchFiltered = searchFiltered.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))
|
|
);
|
|
}
|
|
if (platformFilter) {
|
|
searchFiltered = searchFiltered.filter((t) =>
|
|
t.platform?.toLowerCase().includes(platformFilter.toLowerCase())
|
|
);
|
|
}
|
|
|
|
const reviewState = user.role === "red_lead" ? "red_review" : "blue_review";
|
|
const reviewerField = user.role === "red_lead" ? "red_reviewer_assignee" : "blue_reviewer_assignee";
|
|
const inState = searchFiltered.filter((t) => t.state === reviewState);
|
|
const available = inState.filter((t) => t[reviewerField] !== user.id);
|
|
const mine = inState.filter((t) => t[reviewerField] === user.id);
|
|
|
|
const validationField = user.role === "red_lead" ? "red_validation_status" : "blue_validation_status";
|
|
const pendingValidation = searchFiltered.filter(
|
|
(t) => t.state === "in_review" && !t[validationField]
|
|
);
|
|
|
|
return { availableReviews: available, myAssignedReviews: mine, pendingMyValidation: pendingValidation };
|
|
}, [isReviewLead, user, allTests, searchText, platformFilter, showMyTasks]);
|
|
|
|
// ── Disputed tests — always fetched independently of the my-tasks
|
|
// toggle/state filter so this alert never silently disappears just
|
|
// because the lead is looking at a different view. ──────────────────
|
|
const { data: disputedTests = [] } = useQuery({
|
|
queryKey: ["tests", "disputed-queue"],
|
|
queryFn: () => getTests({ state: "disputed", limit: 200 }),
|
|
enabled: isReviewLead,
|
|
});
|
|
|
|
// ── 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-2 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
|
{ALL_STATES.map((state) => {
|
|
const icons: Record<TestState, React.ReactNode> = {
|
|
draft: <Clock className="h-4 w-4 text-gray-400" />,
|
|
red_executing: <Play className="h-4 w-4 text-orange-400" />,
|
|
red_review: <Shield className="h-4 w-4 text-amber-400" />,
|
|
blue_evaluating: <Shield className="h-4 w-4 text-indigo-400" />,
|
|
blue_review: <Shield className="h-4 w-4 text-purple-400" />,
|
|
in_review: <Eye className="h-4 w-4 text-blue-400" />,
|
|
validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
|
rejected: <XCircle className="h-4 w-4 text-red-400" />,
|
|
disputed: <AlertTriangle className="h-4 w-4 text-amber-400" />,
|
|
};
|
|
const colorMap: Record<TestState, string> = {
|
|
draft: "text-gray-400",
|
|
red_executing: "text-orange-400",
|
|
red_review: "text-amber-400",
|
|
blue_evaluating: "text-indigo-400",
|
|
blue_review: "text-purple-400",
|
|
in_review: "text-blue-400",
|
|
validated: "text-green-400",
|
|
rejected: "text-red-400",
|
|
disputed: "text-amber-400",
|
|
};
|
|
return (
|
|
<button
|
|
key={state}
|
|
onClick={() => {
|
|
if (state === "validated") {
|
|
navigate("/tests/validated");
|
|
return;
|
|
}
|
|
setShowMyTasks(false);
|
|
setStateFilter(stateFilter === state ? "" : state);
|
|
}}
|
|
className={`rounded-xl border p-2.5 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-1.5">
|
|
{icons[state]}
|
|
<span className="text-[11px] text-gray-400 truncate">
|
|
{testStateLabels[state]}
|
|
</span>
|
|
</div>
|
|
<p className={`mt-1.5 text-lg 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 — hidden for tech roles (two-queue view below);
|
|
for leads this switches AWAY from the review two-queue view
|
|
to the in_review dual-validation stage, a distinct responsibility. */}
|
|
{user?.role !== "admin" && user?.role !== "viewer" && !techRole && (
|
|
<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>
|
|
|
|
{/* ── Disputed tests — most urgent, always on top when present ──── */}
|
|
{isReviewLead && disputedTests.length > 0 && (
|
|
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
|
<h2 className="text-lg font-semibold text-amber-400">Disputed</h2>
|
|
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400">
|
|
{disputedTests.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-amber-400/70">Leads disagree on the outcome — needs resolution</span>
|
|
</div>
|
|
<TestTable
|
|
tests={disputedTests}
|
|
columns={mainTableColumns}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
handleSort={handleSort}
|
|
navigate={navigate}
|
|
formatDate={formatDate}
|
|
emptyMessage=""
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Tests Table / Two-Queue View ─────────────────────────────── */}
|
|
{techRole ? (
|
|
/* Two-section queue for red_tech and blue_tech */
|
|
<div className="space-y-6">
|
|
{/* Available Tests */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">Available Tests</h2>
|
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-xs font-medium text-cyan-400">
|
|
{availableTests.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Unassigned — pick one to claim it</span>
|
|
</div>
|
|
<TestTable tests={availableTests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No available tests right now." />
|
|
</div>
|
|
|
|
{/* My Assigned Tests */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">My Assigned Tests</h2>
|
|
<span className="rounded-full border border-orange-500/30 bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-400">
|
|
{myAssignedTests.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Tests assigned to you</span>
|
|
</div>
|
|
<TestTable tests={myAssignedTests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No tests currently assigned to you." />
|
|
</div>
|
|
</div>
|
|
) : reviewQueueActive ? (
|
|
/* Two-section review queue for red_lead/blue_lead */
|
|
<div className="space-y-6">
|
|
{/* Available to Review */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">Available to Review</h2>
|
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-xs font-medium text-cyan-400">
|
|
{availableReviews.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Assigned to other leads — pick one up if needed</span>
|
|
</div>
|
|
<TestTable tests={availableReviews} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No other pending reviews right now." />
|
|
</div>
|
|
|
|
{/* My Assigned Reviews */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">My Assigned Reviews</h2>
|
|
<span className="rounded-full border border-orange-500/30 bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-400">
|
|
{myAssignedReviews.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Reviews assigned to you</span>
|
|
</div>
|
|
<TestTable tests={myAssignedReviews} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No reviews currently assigned to you." />
|
|
</div>
|
|
|
|
{/* Awaiting My Validation — cross-validation stage (in_review),
|
|
distinct from the red_review/blue_review gate above: both
|
|
leads act independently here, so there's no "assigned to
|
|
someone else" bucket, just "have I voted yet or not". */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">Awaiting My Validation</h2>
|
|
<span className="rounded-full border border-blue-500/30 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-400">
|
|
{pendingMyValidation.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Cross-validation — both leads vote independently</span>
|
|
</div>
|
|
<TestTable tests={pendingMyValidation} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="Nothing awaiting your validation right now." />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Normal single-table view for admin, viewer, and leads viewing "My Tasks" */
|
|
<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>
|
|
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Procedure suggestions for leads ───────────────────────────
|
|
GET /procedure-suggestions is already scoped server-side to the
|
|
caller's own team (red_lead sees only red, blue_lead only blue),
|
|
so no client-side filtering is needed here. */}
|
|
{isReviewLead && <ProcedureSuggestionsPanel />}
|
|
|
|
{/* ── Team queue overview for leads ─────────────────────────────
|
|
red_lead/blue_lead never hit the plain "All Tests" branch above —
|
|
they're always routed into the review two-queue view or their
|
|
own "My Tasks" — so they had no way to see every test and who's
|
|
working it, which is what they need to spot a stuck ticket or an
|
|
overloaded operator. Always shown at the end, independent of the
|
|
my-tasks toggle above, with assignees visible on both sides. */}
|
|
{isReviewLead && (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold text-white">All Tests — Team Queue Overview</h2>
|
|
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-300">
|
|
{tests.length}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">Every test, with who's assigned on each side</span>
|
|
</div>
|
|
<TestTable
|
|
tests={tests}
|
|
columns={mainTableColumns}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
handleSort={handleSort}
|
|
navigate={navigate}
|
|
formatDate={formatDate}
|
|
emptyMessage="No tests found matching your filters."
|
|
showAssignees
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Procedure suggestions panel (leads only) ─────────────────────── */
|
|
|
|
function ProcedureSuggestionsPanel() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: suggestions = [] } = useQuery({
|
|
queryKey: ["procedure-suggestions"],
|
|
queryFn: getProcedureSuggestions,
|
|
});
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["procedure-suggestions"] });
|
|
const approveMutation = useMutation({ mutationFn: approveProcedureSuggestion, onSuccess: invalidate });
|
|
const rejectMutation = useMutation({ mutationFn: rejectProcedureSuggestion, onSuccess: invalidate });
|
|
const isBusy = approveMutation.isPending || rejectMutation.isPending;
|
|
|
|
if (suggestions.length === 0) return null;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<Lightbulb className="h-5 w-5 text-yellow-400" />
|
|
<h2 className="text-lg font-semibold text-white">Procedure Suggestions</h2>
|
|
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-300">
|
|
{suggestions.length}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
Commands extracted from submitted rounds, proposed as template updates
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{suggestions.map((s) => (
|
|
<div key={s.id} className="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
|
|
<p className="text-sm font-medium text-gray-200">{s.template_name || "Unknown template"}</p>
|
|
{s.template_current_text && (
|
|
<div className="mt-2">
|
|
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
|
|
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-400">
|
|
{s.template_current_text}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
<div className="mt-2">
|
|
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
|
|
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
|
|
{s.suggested_text}
|
|
</pre>
|
|
</div>
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
onClick={() => approveMutation.mutate(s.id)}
|
|
disabled={isBusy}
|
|
className="flex items-center gap-1 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-1.5 text-xs font-medium text-green-400 hover:bg-green-900/40 disabled:opacity-50"
|
|
>
|
|
<CheckCircle className="h-3.5 w-3.5" /> Approve
|
|
</button>
|
|
<button
|
|
onClick={() => rejectMutation.mutate(s.id)}
|
|
disabled={isBusy}
|
|
className="flex items-center gap-1 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" /> Reject
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Shared test table component ────────────────────────────────────── */
|
|
|
|
function TestTable({
|
|
tests,
|
|
columns,
|
|
sortKey,
|
|
sortDir,
|
|
handleSort,
|
|
navigate,
|
|
formatDate,
|
|
emptyMessage,
|
|
showAssignees = false,
|
|
}: {
|
|
tests: Test[];
|
|
columns: { key: SortKey; label: string; cls: string }[];
|
|
sortKey: SortKey;
|
|
sortDir: "asc" | "desc";
|
|
handleSort: (key: SortKey) => void;
|
|
navigate: (path: string) => void;
|
|
formatDate: (d: string | null | undefined) => string;
|
|
emptyMessage: string;
|
|
/** Adds Red/Blue assignee columns — for the leads' team queue overview,
|
|
* where seeing who's working each side is the whole point. */
|
|
showAssignees?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800">
|
|
{columns.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>
|
|
))}
|
|
{showAssignees && (
|
|
<>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Red Assignee</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Blue Assignee</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]
|
|
}`}
|
|
>
|
|
{test.state === "blue_evaluating" && !test.blue_work_started_at
|
|
? "Queued Blue Team"
|
|
: 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 — how long since Red submitted to Blue */}
|
|
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
|
{test.state === "blue_evaluating" ? (
|
|
<span className="font-mono text-indigo-400">
|
|
{formatElapsed(test.blue_started_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(lastActivityDate(test))}
|
|
</td>
|
|
{showAssignees && (
|
|
<>
|
|
<td className="py-3 px-4 text-xs text-gray-400 whitespace-nowrap">
|
|
{test.red_tech_assignee_username || "-"}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs text-gray-400 whitespace-nowrap">
|
|
{test.blue_tech_assignee_username || "-"}
|
|
</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">{emptyMessage}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|