d49c4aa009
T-119: TestCatalogPage with search, filters (source/platform/severity), template cards grid, and pagination T-120: TestFromTemplateForm modal with pre-filled fields from template, required field validation, and redirect on creation T-121: Integrate Available Test Templates section in TechniqueDetailPage with Run This Test buttons; fix missing testStateBadgeColors for new states Also: add backend entrypoint.sh for automatic Alembic migrations + seed on container startup, add curl to Dockerfile for healthcheck
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useNavigate, useSearchParams, useParams } from "react-router-dom";
|
|
import {
|
|
Loader2,
|
|
Search,
|
|
BookOpen,
|
|
Filter,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
FlaskConical,
|
|
X,
|
|
} from "lucide-react";
|
|
import { getTemplates } from "../api/test-templates";
|
|
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
|
import type { TestTemplateSummary } from "../types/models";
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
const SOURCE_OPTIONS = [
|
|
{ value: "", label: "All Sources" },
|
|
{ value: "atomic_red_team", label: "Atomic Red Team" },
|
|
{ value: "custom", label: "Custom" },
|
|
];
|
|
|
|
const SEVERITY_OPTIONS = [
|
|
{ value: "", label: "All Severities" },
|
|
{ value: "low", label: "Low" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "high", label: "High" },
|
|
{ value: "critical", label: "Critical" },
|
|
];
|
|
|
|
const PLATFORM_OPTIONS = [
|
|
{ value: "", label: "All Platforms" },
|
|
{ value: "windows", label: "Windows" },
|
|
{ value: "linux", label: "Linux" },
|
|
{ value: "macos", label: "macOS" },
|
|
{ value: "azure-ad", label: "Azure AD" },
|
|
{ value: "office-365", label: "Office 365" },
|
|
{ value: "containers", label: "Containers" },
|
|
];
|
|
|
|
const SOURCE_BADGE: Record<string, string> = {
|
|
atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
|
};
|
|
|
|
const SEVERITY_BADGE: Record<string, string> = {
|
|
low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
|
|
high: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
critical: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
};
|
|
|
|
// ── Component ──────────────────────────────────────────────────────
|
|
|
|
export default function TestCatalogPage() {
|
|
const navigate = useNavigate();
|
|
const { templateId } = useParams<{ templateId: string }>();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const [search, setSearch] = useState(searchParams.get("search") || "");
|
|
const [source, setSource] = useState(searchParams.get("source") || "");
|
|
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
|
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
|
const [page, setPage] = useState(0);
|
|
|
|
// Build filters
|
|
const filters = {
|
|
search: search || undefined,
|
|
source: source || undefined,
|
|
platform: platform || undefined,
|
|
severity: severity || undefined,
|
|
offset: page * PAGE_SIZE,
|
|
limit: PAGE_SIZE,
|
|
};
|
|
|
|
const { data: templates = [], isLoading } = useQuery({
|
|
queryKey: ["test-templates", filters],
|
|
queryFn: () => getTemplates(filters),
|
|
});
|
|
|
|
// ── Filter handlers ──────────────────────────────────────────────
|
|
|
|
const applyFilters = () => {
|
|
setPage(0);
|
|
const params = new URLSearchParams();
|
|
if (search) params.set("search", search);
|
|
if (source) params.set("source", source);
|
|
if (platform) params.set("platform", platform);
|
|
if (severity) params.set("severity", severity);
|
|
setSearchParams(params);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearch("");
|
|
setSource("");
|
|
setPlatform("");
|
|
setSeverity("");
|
|
setPage(0);
|
|
setSearchParams({});
|
|
};
|
|
|
|
const hasActiveFilters = search || source || platform || severity;
|
|
|
|
// ── Render ───────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Browse available test templates. Use a template to quickly create a security test.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters bar */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
{/* Search */}
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="mb-1 block text-xs font-medium text-gray-500">Search</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && applyFilters()}
|
|
placeholder="Search templates..."
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Source */}
|
|
<div className="min-w-[150px]">
|
|
<label className="mb-1 block text-xs font-medium text-gray-500">Source</label>
|
|
<select
|
|
value={source}
|
|
onChange={(e) => { setSource(e.target.value); }}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
{SOURCE_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Platform */}
|
|
<div className="min-w-[150px]">
|
|
<label className="mb-1 block text-xs font-medium text-gray-500">Platform</label>
|
|
<select
|
|
value={platform}
|
|
onChange={(e) => { setPlatform(e.target.value); }}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
{PLATFORM_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Severity */}
|
|
<div className="min-w-[140px]">
|
|
<label className="mb-1 block text-xs font-medium text-gray-500">Severity</label>
|
|
<select
|
|
value={severity}
|
|
onChange={(e) => { setSeverity(e.target.value); }}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
{SEVERITY_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<button
|
|
onClick={applyFilters}
|
|
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"
|
|
>
|
|
<Filter className="h-4 w-4" />
|
|
Apply
|
|
</button>
|
|
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
{isLoading ? (
|
|
<div className="flex h-48 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : templates.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-800 bg-gray-900 py-16">
|
|
<BookOpen className="h-12 w-12 text-gray-600" />
|
|
<p className="mt-3 text-gray-400">No templates found</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{hasActiveFilters
|
|
? "Try adjusting your filters or search term."
|
|
: "Import Atomic Red Team tests from the System page to populate the catalog."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Template Grid */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{templates.map((tpl) => (
|
|
<TemplateCard
|
|
key={tpl.id}
|
|
template={tpl}
|
|
onUse={() => navigate(`/test-catalog/${tpl.id}/use`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-gray-500">
|
|
Showing {page * PAGE_SIZE + 1}
|
|
{" - "}
|
|
{page * PAGE_SIZE + templates.length}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Prev
|
|
</button>
|
|
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
|
<button
|
|
onClick={() => setPage((p) => p + 1)}
|
|
disabled={templates.length < PAGE_SIZE}
|
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Template instantiation modal */}
|
|
{templateId && (
|
|
<TestFromTemplateForm
|
|
templateId={templateId}
|
|
onClose={() => navigate("/test-catalog")}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Template Card ──────────────────────────────────────────────────
|
|
|
|
function TemplateCard({
|
|
template,
|
|
onUse,
|
|
}: {
|
|
template: TestTemplateSummary;
|
|
onUse: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col rounded-xl border border-gray-800 bg-gray-900 p-5 transition-colors hover:border-gray-700">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<FlaskConical className="h-5 w-5 shrink-0 text-cyan-400" />
|
|
<h3 className="line-clamp-2 text-sm font-semibold text-white">
|
|
{template.name}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Badges */}
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{/* Technique */}
|
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
|
|
{template.mitre_technique_id}
|
|
</span>
|
|
|
|
{/* Source */}
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
SOURCE_BADGE[template.source] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
|
}`}
|
|
>
|
|
{template.source === "atomic_red_team" ? "Atomic" : template.source}
|
|
</span>
|
|
|
|
{/* Platform */}
|
|
{template.platform && (
|
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs capitalize text-gray-400">
|
|
{template.platform}
|
|
</span>
|
|
)}
|
|
|
|
{/* Severity */}
|
|
{template.severity && (
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${
|
|
SEVERITY_BADGE[template.severity] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
|
}`}
|
|
>
|
|
{template.severity}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Action */}
|
|
<button
|
|
onClick={onUse}
|
|
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
|
>
|
|
<FlaskConical className="h-4 w-4" />
|
|
Use Template
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|