feat(phase-16): enhanced Tests view, Red/Blue dashboard metrics, and Template admin panel (T-122, T-123, T-124)
This commit is contained in:
@@ -1,58 +1,198 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2, AlertCircle, FlaskConical, Plus } from "lucide-react";
|
||||
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
||||
import type { TestState, TestResult } from "../types/models";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Filter,
|
||||
ListChecks,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Play,
|
||||
Shield,
|
||||
Search,
|
||||
} 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 testResultBadgeColors: Record<TestResult, string> = {
|
||||
detected: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||
not_detected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||
partially_detected: "bg-yellow-900/50 text-yellow-400 border-yellow-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",
|
||||
};
|
||||
|
||||
interface TestSummary {
|
||||
id: string;
|
||||
technique_id: string;
|
||||
technique_mitre_id: string;
|
||||
technique_name: string;
|
||||
name: string;
|
||||
state: TestState;
|
||||
result: TestResult | null;
|
||||
platform: string | null;
|
||||
created_at: string;
|
||||
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 "-";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────────── */
|
||||
|
||||
export default function TestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const canCreate =
|
||||
user?.role === "admin" || user?.role === "red_tech" || user?.role === "blue_tech";
|
||||
user?.role === "admin" || user?.role === "red_tech";
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────
|
||||
const [stateFilter, setStateFilter] = useState<TestState | "">("");
|
||||
const [platformFilter, setPlatformFilter] = useState("");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [showMyTasks, setShowMyTasks] = useState(false);
|
||||
|
||||
// Build API filters
|
||||
const filters = useMemo<TestListFilters>(() => {
|
||||
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;
|
||||
}
|
||||
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:
|
||||
// admin: show all
|
||||
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]);
|
||||
|
||||
// For now, we'll fetch techniques to get their tests
|
||||
// In a production app, you'd want a dedicated /tests endpoint
|
||||
const {
|
||||
data: techniques,
|
||||
data: allTests,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["techniques"],
|
||||
queryFn: () => getTechniques(),
|
||||
queryKey: ["tests", filters],
|
||||
queryFn: () => getTests(filters),
|
||||
});
|
||||
|
||||
// Note: Since we don't have a direct /tests list endpoint, we're showing
|
||||
// a message to navigate through techniques. In a full implementation,
|
||||
// you'd add a /tests endpoint to the backend.
|
||||
// Client-side filtering for search text and "my tasks" for red_tech
|
||||
const tests = useMemo(() => {
|
||||
if (!allTests) return [];
|
||||
let filtered = allTests;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
// 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))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [allTests, searchText, showMyTasks, user, stateFilter]);
|
||||
|
||||
// ── 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;
|
||||
if (allTests) {
|
||||
for (const t of allTests) {
|
||||
counts[t.state] = (counts[t.state] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [allTests]);
|
||||
|
||||
// Count from unfiltered query for the top cards
|
||||
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) => {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
@@ -60,6 +200,25 @@ export default function TestsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// ── 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">
|
||||
@@ -72,7 +231,7 @@ export default function TestsPage() {
|
||||
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 data</p>
|
||||
<p className="text-red-400">Failed to load tests</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +243,7 @@ export default function TestsPage() {
|
||||
<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
|
||||
Security tests for technique validation — Red/Blue workflow
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
@@ -98,128 +257,233 @@ export default function TestsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-cyan-500/20 p-3">
|
||||
<FlaskConical className="h-6 w-6 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Browse Tests by Technique</h2>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Tests are organized by MITRE ATT&CK technique. Navigate to a technique from the{" "}
|
||||
<button
|
||||
onClick={() => navigate("/techniques")}
|
||||
className="text-cyan-400 hover:underline"
|
||||
>
|
||||
Techniques page
|
||||
</button>{" "}
|
||||
to view and manage its associated tests.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => navigate("/techniques")}
|
||||
className="rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
Browse Techniques Matrix
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button
|
||||
onClick={() => navigate("/tests/new")}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
|
||||
>
|
||||
Create Standalone Test
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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={() => {
|
||||
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>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
|
||||
<p className="text-sm text-gray-400">Techniques with Tests</p>
|
||||
<p className="mt-1 text-2xl font-bold text-cyan-400">
|
||||
{techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
|
||||
<p className="text-sm text-gray-400">Validated</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-400">
|
||||
{techniques?.filter((t: TechniqueSummary) => t.status_global === "validated").length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
|
||||
<p className="text-sm text-gray-400">In Progress</p>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||
{techniques?.filter((t: TechniqueSummary) => t.status_global === "in_progress").length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
|
||||
<p className="text-sm text-gray-400">Pending Evaluation</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-400">
|
||||
{techniques?.filter((t: TechniqueSummary) => t.status_global === "not_evaluated").length || 0}
|
||||
</p>
|
||||
{/* ── 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) => setStateFilter(e.target.value as TestState | "")}
|
||||
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.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>
|
||||
|
||||
{/* Techniques with Recent Activity */}
|
||||
{/* ── Tests Table ───────────────────────────────────────────────── */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Techniques Being Tested</h2>
|
||||
<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">
|
||||
<th className="pb-3 pr-4 font-medium text-gray-400">Technique</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Name</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
|
||||
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Current Team</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Updated</th>
|
||||
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{techniques
|
||||
?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated")
|
||||
.slice(0, 10)
|
||||
.map((tech: TechniqueSummary) => (
|
||||
<tr
|
||||
key={tech.id}
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="font-mono text-cyan-400">{tech.mitre_id}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-200">{tech.name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
tech.status_global === "validated"
|
||||
? testStateBadgeColors.validated
|
||||
: tech.status_global === "in_progress"
|
||||
? testStateBadgeColors.in_review
|
||||
: tech.status_global === "partial"
|
||||
? "bg-yellow-900/50 text-yellow-400 border-yellow-500/30"
|
||||
: testStateBadgeColors.draft
|
||||
}`}
|
||||
>
|
||||
{tech.status_global.replace(/_/g, " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<button
|
||||
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
||||
className="text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
View Tests
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{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>
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
{formatDate(test.created_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>
|
||||
{techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length === 0 && (
|
||||
<div className="py-8 text-center text-gray-400">
|
||||
No techniques have been tested yet. Create your first test to get started.
|
||||
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user