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:
2026-02-09 13:00:07 +01:00
parent fd7f855008
commit a95defcee4
12 changed files with 1769 additions and 159 deletions

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Shield,
CheckCircle,
@@ -9,12 +10,54 @@ import {
Percent,
Loader2,
AlertCircle,
Play,
Eye,
Users,
TrendingUp,
ArrowRight,
} from "lucide-react";
import { getCoverageSummary, getCoverageByTactic } from "../api/metrics";
import {
getCoverageSummary,
getCoverageByTactic,
getTestPipeline,
getTeamActivity,
getValidationRate,
getRecentTests,
type TestPipelineCounts,
type TeamActivityItem,
type ValidationRateItem,
type RecentTestItem,
} from "../api/metrics";
import CoverageSummaryCard from "../components/CoverageSummaryCard";
import TacticCoverageChart from "../components/TacticCoverageChart";
import type { TestState } from "../types/models";
/* ── Badge colours (reused from TestsPage) ─────────────────────────── */
const testStateBadgeColors: Record<string, 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<string, string> = {
draft: "Draft",
red_executing: "Red Executing",
blue_evaluating: "Blue Evaluating",
in_review: "In Review",
validated: "Validated",
rejected: "Rejected",
};
/* ── Component ──────────────────────────────────────────────────────── */
export default function DashboardPage() {
const navigate = useNavigate();
// Existing coverage queries
const {
data: summary,
isLoading: summaryLoading,
@@ -33,6 +76,27 @@ export default function DashboardPage() {
queryFn: getCoverageByTactic,
});
// V2 queries
const { data: pipeline, isLoading: pipelineLoading } = useQuery({
queryKey: ["metrics", "test-pipeline"],
queryFn: getTestPipeline,
});
const { data: teamActivity, isLoading: teamLoading } = useQuery({
queryKey: ["metrics", "team-activity"],
queryFn: getTeamActivity,
});
const { data: validationRates, isLoading: validationLoading } = useQuery({
queryKey: ["metrics", "validation-rate"],
queryFn: getValidationRate,
});
const { data: recentTests, isLoading: recentLoading } = useQuery({
queryKey: ["metrics", "recent-tests"],
queryFn: getRecentTests,
});
if (summaryLoading || tacticsLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -127,8 +191,263 @@ export default function DashboardPage() {
</div>
)}
{/* Tactic Coverage Table */}
{/* ── V2 Section: Test Pipeline ────────────────────────────────── */}
<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 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Test Pipeline
</h2>
<button
onClick={() => navigate("/tests")}
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
>
View all tests <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
{pipelineLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : pipeline ? (
<PipelineFunnel pipeline={pipeline} />
) : null}
</div>
{/* ── V2 Section: Team Activity + Validation Rate ──────────────── */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Team Activity */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
<Users className="h-5 w-5 text-cyan-400" />
Team Activity
</h2>
{teamLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : teamActivity ? (
<div className="space-y-4">
{teamActivity.map((team: TeamActivityItem) => {
const isRed = team.team.toLowerCase().includes("red");
const total = team.tests_completed + team.tests_pending;
const pct = total > 0 ? (team.tests_completed / total) * 100 : 0;
return (
<div key={team.team} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
/>
<span className="font-medium text-white">{team.team}</span>
</div>
<span className="text-sm text-gray-400">
{team.tests_completed} completed / {team.tests_pending} pending
</span>
</div>
<div className="h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{pct.toFixed(0)}% completion rate
</p>
</div>
);
})}
</div>
) : null}
</div>
{/* Validation Rate */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-cyan-400" />
Validation Rate
</h2>
{validationLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : validationRates ? (
<div className="space-y-4">
{validationRates.map((rate: ValidationRateItem) => {
const isRed = rate.role === "red_lead";
return (
<div key={rate.role} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
/>
<span className="font-medium text-white">
{isRed ? "Red Lead" : "Blue Lead"}
</span>
</div>
<span className="text-sm text-gray-400">
{rate.total_reviewed} reviewed
</span>
</div>
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5 text-green-400" />
<span className="text-green-400">{rate.approved} approved</span>
</div>
<div className="flex items-center gap-1">
<XCircle className="h-3.5 w-3.5 text-red-400" />
<span className="text-red-400">{rate.rejected} rejected</span>
</div>
</div>
<div className="mt-3 h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-green-500 transition-all"
style={{ width: `${rate.approval_rate}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{rate.approval_rate}% approval rate
</p>
</div>
);
})}
</div>
) : null}
</div>
</div>
{/* ── V2 Section: Recent Tests ─────────────────────────────────── */}
<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">Recent Tests</h2>
<button
onClick={() => navigate("/tests")}
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
{recentLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : recentTests && recentTests.length > 0 ? (
<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">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 pl-4 font-medium text-gray-400">Created</th>
</tr>
</thead>
<tbody>
{recentTests.map((t: RecentTestItem) => (
<tr
key={t.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/tests/${t.id}`)}
>
<td className="py-3 pr-4 font-medium text-gray-200">{t.name}</td>
<td className="py-3 px-4">
{t.technique_mitre_id ? (
<span className="font-mono text-xs text-cyan-400">
{t.technique_mitre_id}
</span>
) : (
<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[t.state] || "bg-gray-800 text-gray-400 border-gray-600"
}`}
>
{testStateLabels[t.state] || t.state}
</span>
</td>
<td className="py-3 pl-4 text-gray-400 text-xs">
{t.created_at
? new Date(t.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center text-gray-400">
No tests created yet.
</div>
)}
</div>
{/* Tactic Coverage Table (original V1) */}
{tactics && <TacticCoverageChart data={tactics} />}
</div>
);
}
/* ── Pipeline Funnel Sub-component ─────────────────────────────────── */
function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
{ key: "draft", label: "Draft", color: "bg-gray-600", icon: <Clock className="h-4 w-4 text-gray-400" /> },
{ key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: <Play className="h-4 w-4 text-orange-400" /> },
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: <Shield className="h-4 w-4 text-indigo-400" /> },
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
];
const maxCount = Math.max(...stages.map((s) => pipeline[s.key] as number), 1);
return (
<div className="space-y-3">
{stages.map((stage) => {
const count = pipeline[stage.key] as number;
const pct = (count / maxCount) * 100;
return (
<div key={stage.key} className="flex items-center gap-3">
<div className="flex items-center gap-2 w-36 shrink-0">
{stage.icon}
<span className="text-sm text-gray-300 truncate">{stage.label}</span>
</div>
<div className="flex-1 h-6 rounded bg-gray-800 overflow-hidden">
<div
className={`h-full rounded ${stage.color} transition-all flex items-center justify-end px-2`}
style={{ width: `${Math.max(pct, count > 0 ? 8 : 0)}%` }}
>
{count > 0 && (
<span className="text-xs font-medium text-white">{count}</span>
)}
</div>
</div>
<span className="text-sm font-mono text-gray-400 w-8 text-right">{count}</span>
</div>
);
})}
<div className="pt-2 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total tests</span>
<span className="font-medium text-white">{pipeline.total}</span>
</div>
</div>
);
}

View File

@@ -12,6 +12,13 @@ import {
XCircle,
Shield,
Search,
FlaskConical,
Download,
Plus,
ToggleLeft,
ToggleRight,
BarChart3,
X,
} from "lucide-react";
import {
triggerMitreSync,
@@ -20,12 +27,26 @@ import {
type SyncMitreResponse,
type IntelScanResponse,
} from "../api/system";
import {
importAtomicTests,
getTemplateStats,
getAllTemplates,
createTemplate,
toggleTemplateActive,
type ImportAtomicResponse,
type TemplateStats,
type CreateTemplatePayload,
} from "../api/test-templates";
import type { TestTemplate } from "../types/models";
export default function SystemPage() {
const queryClient = useQueryClient();
const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null);
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
const [importResult, setImportResult] = useState<ImportAtomicResponse | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
// ── Existing queries ─────────────────────────────────────────────
const {
data: schedulerStatus,
isLoading: statusLoading,
@@ -33,9 +54,27 @@ export default function SystemPage() {
} = useQuery({
queryKey: ["scheduler-status"],
queryFn: getSchedulerStatus,
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: 30000,
});
// ── Template queries ─────────────────────────────────────────────
const {
data: templateStats,
isLoading: statsLoading,
} = useQuery({
queryKey: ["template-stats"],
queryFn: getTemplateStats,
});
const {
data: templates,
isLoading: templatesLoading,
} = useQuery({
queryKey: ["templates-admin"],
queryFn: () => getAllTemplates({ limit: 100 }),
});
// ── Mutations ────────────────────────────────────────────────────
const mitreSyncMutation = useMutation({
mutationFn: triggerMitreSync,
onSuccess: (data) => {
@@ -53,6 +92,35 @@ export default function SystemPage() {
},
});
const importAtomicMutation = useMutation({
mutationFn: importAtomicTests,
onSuccess: (data) => {
setImportResult(data);
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
},
});
const toggleActiveMutation = useMutation({
mutationFn: (id: string) => toggleTemplateActive(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
},
});
const createTemplateMutation = useMutation({
mutationFn: (payload: CreateTemplatePayload) => createTemplate(payload),
onSuccess: () => {
setShowCreateForm(false);
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
},
});
const formatNextRun = (dateStr: string | null) => {
if (!dateStr) return "Not scheduled";
const date = new Date(dateStr);
@@ -68,7 +136,7 @@ export default function SystemPage() {
<div>
<h1 className="text-2xl font-bold text-white">System Administration</h1>
<p className="mt-1 text-sm text-gray-400">
Manage synchronization jobs and system status
Manage synchronization jobs, templates, and system status
</p>
</div>
@@ -86,7 +154,6 @@ export default function SystemPage() {
Synchronize techniques from the MITRE ATT&CK framework via TAXII or GitHub fallback.
</p>
{/* Status */}
{schedulerStatus && (
<div className="mt-4 flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-gray-500" />
@@ -99,7 +166,6 @@ export default function SystemPage() {
</div>
)}
{/* Result */}
{syncResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2">
@@ -158,7 +224,6 @@ export default function SystemPage() {
Scan RSS feeds and security blogs for new threat intelligence related to techniques.
</p>
{/* Status */}
{schedulerStatus && (
<div className="mt-4 flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-gray-500" />
@@ -171,7 +236,6 @@ export default function SystemPage() {
</div>
)}
{/* Result */}
{intelResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2">
@@ -213,11 +277,278 @@ export default function SystemPage() {
</div>
</div>
{/* ────────────────────────────────────────────────────────────────
TEMPLATE ADMINISTRATION (T-124)
──────────────────────────────────────────────────────────────── */}
{/* Import Atomic Red Team + Stats */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Import Atomic Red Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-red-500/10 p-3">
<Download className="h-6 w-6 text-red-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">Import Atomic Red Team</h2>
<p className="mt-1 text-sm text-gray-400">
Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques.
</p>
{importResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">Import Complete</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-gray-400">Imported:</span>
<span className="ml-1 font-medium text-white">{importResult.imported}</span>
</div>
<div>
<span className="text-gray-400">Skipped:</span>
<span className="ml-1 font-medium text-white">{importResult.skipped}</span>
</div>
<div>
<span className="text-gray-400">Parsed:</span>
<span className="ml-1 font-medium text-white">{importResult.total_parsed}</span>
</div>
</div>
</div>
)}
{importAtomicMutation.isError && (
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">
Import failed: {(importAtomicMutation.error as Error)?.message}
</span>
</div>
</div>
)}
<button
onClick={() => importAtomicMutation.mutate()}
disabled={importAtomicMutation.isPending}
className="mt-4 flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{importAtomicMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{importAtomicMutation.isPending ? "Importing..." : "Import Now"}
</button>
</div>
</div>
</div>
{/* Template Catalog Stats */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-yellow-500/10 p-3">
<BarChart3 className="h-6 w-6 text-yellow-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">Catalog Statistics</h2>
<p className="mt-1 text-sm text-gray-400">
Overview of the test template catalog.
</p>
{statsLoading ? (
<div className="mt-4 flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-cyan-400" />
</div>
) : templateStats ? (
<div className="mt-4 space-y-4">
{/* Totals */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-cyan-400">{templateStats.total}</p>
<p className="text-xs text-gray-400">Total</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-green-400">{templateStats.active}</p>
<p className="text-xs text-gray-400">Active</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-gray-400">{templateStats.inactive}</p>
<p className="text-xs text-gray-400">Inactive</p>
</div>
</div>
{/* By source */}
<div>
<p className="text-xs font-medium uppercase text-gray-500 mb-2">By Source</p>
<div className="flex flex-wrap gap-2">
{Object.entries(templateStats.by_source).map(([source, count]) => (
<span
key={source}
className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-300"
>
{source.replace(/_/g, " ")}
<span className="font-medium text-cyan-400">{count}</span>
</span>
))}
{Object.keys(templateStats.by_source).length === 0 && (
<span className="text-xs text-gray-500">No templates yet</span>
)}
</div>
</div>
{/* By platform */}
<div>
<p className="text-xs font-medium uppercase text-gray-500 mb-2">By Platform</p>
<div className="flex flex-wrap gap-2">
{Object.entries(templateStats.by_platform).map(([platform, count]) => (
<span
key={platform}
className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-300"
>
{platform}
<span className="font-medium text-cyan-400">{count}</span>
</span>
))}
{Object.keys(templateStats.by_platform).length === 0 && (
<span className="text-xs text-gray-500">No templates yet</span>
)}
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
{/* Create Custom Template Form (modal-style inline) */}
{showCreateForm && (
<CreateTemplateForm
onClose={() => setShowCreateForm(false)}
onSubmit={(payload) => createTemplateMutation.mutate(payload)}
isPending={createTemplateMutation.isPending}
error={createTemplateMutation.isError ? (createTemplateMutation.error as Error)?.message : null}
/>
)}
{/* Templates Management 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 flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-cyan-400" />
Manage Templates
</h2>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
Create Custom Template
</button>
</div>
{templatesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : templates && templates.length > 0 ? (
<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">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">Source</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">Status</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
</tr>
</thead>
<tbody>
{(templates as TestTemplate[]).map((tpl) => (
<tr
key={tpl.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="py-3 pr-4">
<span className="font-medium text-gray-200 truncate block max-w-[200px]">
{tpl.name}
</span>
</td>
<td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400">
{tpl.mitre_technique_id}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
tpl.source === "atomic_red_team"
? "bg-red-900/50 text-red-400 border-red-500/30"
: tpl.source === "mitre"
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
}`}
>
{tpl.source.replace(/_/g, " ")}
</span>
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{tpl.platform || "-"}
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
tpl.is_active
? "bg-green-900/50 text-green-400 border-green-500/30"
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
}`}
>
{tpl.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="py-3 pl-4">
<button
onClick={() => toggleActiveMutation.mutate(tpl.id)}
disabled={toggleActiveMutation.isPending}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
tpl.is_active
? "text-yellow-400 hover:text-yellow-300"
: "text-green-400 hover:text-green-300"
}`}
title={tpl.is_active ? "Deactivate" : "Activate"}
>
{tpl.is_active ? (
<>
<ToggleRight className="h-4 w-4" />
Deactivate
</>
) : (
<>
<ToggleLeft className="h-4 w-4" />
Activate
</>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center text-gray-400">
No templates found. Import from Atomic Red Team or create a custom template.
</div>
)}
</div>
{/* System Information */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">System Information</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Backend Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-400" />
@@ -227,8 +558,6 @@ export default function SystemPage() {
</div>
</div>
</div>
{/* Database Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-green-400" />
@@ -238,8 +567,6 @@ export default function SystemPage() {
</div>
</div>
</div>
{/* MinIO Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-green-400" />
@@ -249,8 +576,6 @@ export default function SystemPage() {
</div>
</div>
</div>
{/* Scheduler Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3">
<Clock
@@ -368,3 +693,210 @@ export default function SystemPage() {
</div>
);
}
/* ── Create Template Form (inline modal) ──────────────────────────── */
function CreateTemplateForm({
onClose,
onSubmit,
isPending,
error,
}: {
onClose: () => void;
onSubmit: (payload: CreateTemplatePayload) => void;
isPending: boolean;
error: string | null;
}) {
const [form, setForm] = useState<CreateTemplatePayload>({
mitre_technique_id: "",
name: "",
description: "",
source: "custom",
attack_procedure: "",
expected_detection: "",
platform: "",
tool_suggested: "",
severity: "",
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.mitre_technique_id || !form.name) return;
onSubmit(form);
};
return (
<div className="rounded-xl border border-cyan-500/30 bg-gray-900 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Plus className="h-5 w-5 text-cyan-400" />
Create Custom Template
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* MITRE Technique ID */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
MITRE Technique ID *
</label>
<input
type="text"
value={form.mitre_technique_id}
onChange={(e) => setForm({ ...form, mitre_technique_id: e.target.value })}
placeholder="e.g. T1059.001"
required
className="w-full 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"
/>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Template Name *
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Test template name"
required
className="w-full 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"
/>
</div>
{/* Platform */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Platform
</label>
<select
value={form.platform || ""}
onChange={(e) => setForm({ ...form, platform: e.target.value || undefined })}
className="w-full 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="">Select platform...</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
</select>
</div>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Severity
</label>
<select
value={form.severity || ""}
onChange={(e) => setForm({ ...form, severity: e.target.value || undefined })}
className="w-full 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="">Select severity...</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
value={form.description || ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Template description..."
rows={2}
className="w-full 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"
/>
</div>
{/* Attack Procedure */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Attack Procedure
</label>
<textarea
value={form.attack_procedure || ""}
onChange={(e) => setForm({ ...form, attack_procedure: e.target.value })}
placeholder="Steps for the red team to execute..."
rows={3}
className="w-full 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"
/>
</div>
{/* Expected Detection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Expected Detection
</label>
<textarea
value={form.expected_detection || ""}
onChange={(e) => setForm({ ...form, expected_detection: e.target.value })}
placeholder="What the blue team should detect..."
rows={2}
className="w-full 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"
/>
</div>
{/* Tool Suggested */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Suggested Tool
</label>
<input
type="text"
value={form.tool_suggested || ""}
onChange={(e) => setForm({ ...form, tool_suggested: e.target.value })}
placeholder="e.g. PowerShell, Cobalt Strike"
className="w-full 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"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
</div>
</div>
)}
{/* Buttons */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={isPending || !form.mitre_technique_id || !form.name}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{isPending ? "Creating..." : "Create Template"}
</button>
<button
type="button"
onClick={onClose}
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"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -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>