0001b33594
SystemPage now only shows operational content: MITRE Sync, Intel Scan, ATT&CK Evaluations, Scheduled Jobs, and Template Management. Settings gets two new admin-only tabs: - "SSO / Azure AD": full SAML 2.0 wizard (5-step setup for Azure AD) - "System": System Status + Version Info + Configuration Export/Import
1753 lines
76 KiB
TypeScript
1753 lines
76 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Loader2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
Clock,
|
|
CheckCircle,
|
|
XCircle,
|
|
Shield,
|
|
Search,
|
|
FlaskConical,
|
|
Plus,
|
|
ToggleLeft,
|
|
ToggleRight,
|
|
BarChart3,
|
|
X,
|
|
Download,
|
|
Swords,
|
|
Sparkles,
|
|
AlertTriangle,
|
|
ExternalLink,
|
|
CalendarCheck,
|
|
ShieldCheck,
|
|
} from "lucide-react";
|
|
import {
|
|
triggerMitreSync,
|
|
triggerIntelScan,
|
|
getSchedulerStatus,
|
|
listEvaluationRounds,
|
|
importEvaluationRound,
|
|
importLatestEvaluation,
|
|
checkNewEvaluationRound,
|
|
bulkApproveEvaluationTests,
|
|
getEvalPendingCount,
|
|
reEnrichEvaluationRound,
|
|
type SyncMitreResponse,
|
|
type IntelScanResponse,
|
|
type EvaluationRound,
|
|
type EvaluationRoundsResponse,
|
|
type EvaluationImportResult,
|
|
type NewRoundCheckResult,
|
|
type BulkApproveResult,
|
|
type ReEnrichResult,
|
|
} from "../api/system";
|
|
import {
|
|
getTemplateStats,
|
|
getAllTemplates,
|
|
getTemplateById,
|
|
createTemplate,
|
|
updateTemplate,
|
|
toggleTemplateActive,
|
|
bulkActivateTemplates,
|
|
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 [showCreateForm, setShowCreateForm] = useState(false);
|
|
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
|
|
|
// ── ATT&CK Evaluations state ─────────────────────────────────────
|
|
const [evalImportResult, setEvalImportResult] = useState<EvaluationImportResult | null>(null);
|
|
const [evalCheckResult, setEvalCheckResult] = useState<NewRoundCheckResult | null>(null);
|
|
const [evalImportingRound, setEvalImportingRound] = useState<string | null>(null);
|
|
const [showBulkApproveModal, setShowBulkApproveModal] = useState(false);
|
|
const [bulkApproveResult, setBulkApproveResult] = useState<BulkApproveResult | null>(null);
|
|
const [reEnrichingRound, setReEnrichingRound] = useState<string | null>(null);
|
|
const [reEnrichResult, setReEnrichResult] = useState<ReEnrichResult | null>(null);
|
|
|
|
// ── Existing queries ─────────────────────────────────────────────
|
|
const {
|
|
data: schedulerStatus,
|
|
isLoading: statusLoading,
|
|
error: statusError,
|
|
} = useQuery({
|
|
queryKey: ["scheduler-status"],
|
|
queryFn: getSchedulerStatus,
|
|
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: 200 }),
|
|
});
|
|
|
|
const {
|
|
data: selectedTemplate,
|
|
isLoading: selectedTemplateLoading,
|
|
} = useQuery({
|
|
queryKey: ["template-detail", selectedTemplateId],
|
|
queryFn: () => getTemplateById(selectedTemplateId!),
|
|
enabled: !!selectedTemplateId,
|
|
});
|
|
|
|
// ── Mutations ────────────────────────────────────────────────────
|
|
const mitreSyncMutation = useMutation({
|
|
mutationFn: triggerMitreSync,
|
|
onSuccess: (data) => {
|
|
setSyncResult(data);
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics"] });
|
|
},
|
|
});
|
|
|
|
const intelScanMutation = useMutation({
|
|
mutationFn: triggerIntelScan,
|
|
onSuccess: (data) => {
|
|
setIntelResult(data);
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
},
|
|
});
|
|
|
|
const bulkActivateMutation = useMutation({
|
|
mutationFn: (activate: boolean) => bulkActivateTemplates(activate),
|
|
onSuccess: () => {
|
|
setBulkConfirm(null);
|
|
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
|
|
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
|
|
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 updateTemplateMutation = useMutation({
|
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<CreateTemplatePayload> }) =>
|
|
updateTemplate(id, payload),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
|
|
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
|
|
queryClient.invalidateQueries({ queryKey: ["template-detail", selectedTemplateId] });
|
|
},
|
|
});
|
|
|
|
// ── ATT&CK Evaluations queries & mutations ───────────────────────
|
|
const {
|
|
data: evalRoundsData,
|
|
isLoading: evalRoundsLoading,
|
|
refetch: refetchEvalRounds,
|
|
} = useQuery<EvaluationRoundsResponse>({
|
|
queryKey: ["eval-rounds"],
|
|
queryFn: listEvaluationRounds,
|
|
});
|
|
|
|
const evalRounds = evalRoundsData?.rounds;
|
|
const evalApiReachable = evalRoundsData?.api_reachable ?? true;
|
|
const evalApiError = evalRoundsData?.api_error ?? null;
|
|
|
|
const checkNewRoundMutation = useMutation({
|
|
mutationFn: checkNewEvaluationRound,
|
|
onSuccess: (data) => {
|
|
setEvalCheckResult(data);
|
|
refetchEvalRounds();
|
|
},
|
|
});
|
|
|
|
const importLatestMutation = useMutation({
|
|
mutationFn: importLatestEvaluation,
|
|
onSuccess: (data) => {
|
|
setEvalImportResult(data);
|
|
setEvalImportingRound(null);
|
|
refetchEvalRounds();
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics"] });
|
|
},
|
|
onError: () => {
|
|
setEvalImportingRound(null);
|
|
},
|
|
});
|
|
|
|
const importRoundMutation = useMutation({
|
|
mutationFn: (payload: { adversary_name: string; adversary_display: string; eval_round: number }) =>
|
|
importEvaluationRound(payload),
|
|
onSuccess: (data) => {
|
|
setEvalImportResult(data);
|
|
setEvalImportingRound(null);
|
|
refetchEvalRounds();
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics"] });
|
|
},
|
|
onError: () => {
|
|
setEvalImportingRound(null);
|
|
},
|
|
});
|
|
|
|
const {
|
|
data: evalPendingData,
|
|
refetch: refetchPendingCount,
|
|
} = useQuery({
|
|
queryKey: ["eval-pending-count"],
|
|
queryFn: getEvalPendingCount,
|
|
});
|
|
|
|
const bulkApproveMutation = useMutation({
|
|
mutationFn: bulkApproveEvaluationTests,
|
|
onSuccess: (data) => {
|
|
setBulkApproveResult(data);
|
|
setShowBulkApproveModal(false);
|
|
refetchPendingCount();
|
|
refetchEvalRounds();
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics"] });
|
|
queryClient.invalidateQueries({ queryKey: ["review-queue"] });
|
|
},
|
|
});
|
|
|
|
const reEnrichMutation = useMutation({
|
|
mutationFn: (payload: { adversary_name: string; adversary_display: string; eval_round: number }) =>
|
|
reEnrichEvaluationRound(payload),
|
|
onSuccess: (data) => {
|
|
setReEnrichResult(data);
|
|
setReEnrichingRound(null);
|
|
},
|
|
onError: () => {
|
|
setReEnrichingRound(null);
|
|
},
|
|
});
|
|
|
|
const formatNextRun = (dateStr: string | null) => {
|
|
if (!dateStr) return "Not scheduled";
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString("en-US", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">System Administration</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Manage synchronization jobs, templates, and system status
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions Grid */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* MITRE Sync */}
|
|
<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-cyan-500/10 p-3">
|
|
<Shield className="h-6 w-6 text-cyan-400" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-lg font-semibold text-white">MITRE ATT&CK Sync</h2>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Synchronize techniques from the MITRE ATT&CK framework via TAXII or GitHub fallback.
|
|
</p>
|
|
|
|
{schedulerStatus && (
|
|
<div className="mt-4 flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 text-gray-500" />
|
|
<span className="text-gray-400">Next automatic sync:</span>
|
|
<span className="text-gray-300">
|
|
{formatNextRun(
|
|
schedulerStatus.jobs.find((j) => j.id === "mitre_sync")?.next_run_time || null
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{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">
|
|
<CheckCircle className="h-4 w-4 text-green-400" />
|
|
<span className="text-sm font-medium text-green-400">
|
|
{syncResult.status === "started" ? "Sync Started" : "Sync Complete"}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-gray-400">{syncResult.message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{mitreSyncMutation.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">
|
|
Sync failed: {(mitreSyncMutation.error as Error)?.message}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => mitreSyncMutation.mutate()}
|
|
disabled={mitreSyncMutation.isPending}
|
|
className="mt-4 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"
|
|
>
|
|
{mitreSyncMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
{mitreSyncMutation.isPending ? "Syncing..." : "Sync Now"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Intel Scan */}
|
|
<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-purple-500/10 p-3">
|
|
<Search className="h-6 w-6 text-purple-400" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-lg font-semibold text-white">Security Feed Monitor</h2>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Monitor public RSS feeds and security blogs for new articles and research related to MITRE ATT&CK techniques.
|
|
</p>
|
|
|
|
{schedulerStatus && (
|
|
<div className="mt-4 flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 text-gray-500" />
|
|
<span className="text-gray-400">Next automatic scan:</span>
|
|
<span className="text-gray-300">
|
|
{formatNextRun(
|
|
schedulerStatus.jobs.find((j) => j.id === "intel_scan")?.next_run_time || null
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{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">
|
|
<CheckCircle className="h-4 w-4 text-green-400" />
|
|
<span className="text-sm font-medium text-green-400">Scan Complete</span>
|
|
</div>
|
|
<div className="mt-2 text-sm">
|
|
<span className="text-gray-400">New intel items:</span>
|
|
<span className="ml-2 font-medium text-white">{intelResult.new_items}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{intelScanMutation.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">
|
|
Scan failed: {(intelScanMutation.error as Error)?.message}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => intelScanMutation.mutate()}
|
|
disabled={intelScanMutation.isPending}
|
|
className="mt-4 flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-500 disabled:opacity-50 transition-colors"
|
|
>
|
|
{intelScanMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Search className="h-4 w-4" />
|
|
)}
|
|
{intelScanMutation.isPending ? "Scanning..." : "Scan Now"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ────────────────────────────────────────────────────────────────
|
|
ATT&CK EVALUATIONS — CrowdStrike
|
|
──────────────────────────────────────────────────────────────── */}
|
|
<div className="rounded-xl border border-orange-500/30 bg-gray-900 p-6">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
|
<div className="flex items-start gap-4">
|
|
<div className="rounded-lg bg-orange-500/10 p-3 mt-0.5">
|
|
<Swords className="h-6 w-6 text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">
|
|
ATT&CK Evaluations — CrowdStrike Falcon
|
|
</h2>
|
|
<p className="mt-1 text-sm text-gray-400 max-w-2xl">
|
|
Seed the platform with real detection data from MITRE Engenuity's public ATT&CK
|
|
Evaluations. Results are imported as <span className="text-yellow-400 font-medium">In Review</span> tests —
|
|
Blue Leads must validate each one before it counts as coverage.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0 flex-wrap">
|
|
<button
|
|
onClick={() => checkNewRoundMutation.mutate()}
|
|
disabled={checkNewRoundMutation.isPending || evalRoundsLoading}
|
|
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{checkNewRoundMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
Check for new rounds
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setEvalImportingRound("latest");
|
|
importLatestMutation.mutate();
|
|
}}
|
|
disabled={importLatestMutation.isPending || importRoundMutation.isPending}
|
|
className="flex items-center gap-2 rounded-lg bg-orange-600 px-3 py-2 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50 transition-colors"
|
|
>
|
|
{importLatestMutation.isPending && evalImportingRound === "latest" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Sparkles className="h-4 w-4" />
|
|
)}
|
|
Import Latest Round
|
|
</button>
|
|
{(evalPendingData?.pending ?? 0) > 0 && (
|
|
<button
|
|
onClick={() => setShowBulkApproveModal(true)}
|
|
className="flex items-center gap-2 rounded-lg bg-green-700 px-3 py-2 text-sm font-medium text-white hover:bg-green-600 transition-colors"
|
|
>
|
|
<ShieldCheck className="h-4 w-4" />
|
|
Bulk Approve
|
|
<span className="ml-1 rounded-full bg-green-500/30 px-1.5 py-0.5 text-xs font-bold">
|
|
{evalPendingData?.pending ?? 0}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Disclaimer callout */}
|
|
<div className="mb-5 flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-900/10 p-4">
|
|
<AlertTriangle className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm">
|
|
<p className="font-medium text-yellow-400 mb-1">Lab environment data — validation required</p>
|
|
<p className="text-gray-400">
|
|
These results reflect CrowdStrike Falcon performance in a controlled MITRE lab against
|
|
simulated adversaries. They do <strong className="text-white">not</strong> represent
|
|
your organisation's actual detection capability. All imported tests require Blue Lead
|
|
validation before contributing to programme coverage.
|
|
</p>
|
|
<a
|
|
href="https://evals.mitre.org"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1 text-xs text-orange-400 hover:text-orange-300 transition-colors"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
View official evaluation reports at evals.mitre.org
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* API fallback warning — only when no rounds loaded at all */}
|
|
{!evalApiReachable && evalApiError && (!evalRounds || evalRounds.length === 0) && (
|
|
<div className="mb-4 flex items-start gap-3 rounded-lg border border-orange-500/30 bg-orange-900/10 p-3">
|
|
<AlertCircle className="h-4 w-4 text-orange-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-orange-300">
|
|
<span className="font-medium">MITRE Evaluations API temporarily unavailable</span>
|
|
{" — "}could not load round list. The server may be overloaded. Try again later.
|
|
<span className="block mt-1 text-orange-400/70 font-mono truncate">{evalApiError}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Soft note when using fallback data but rounds are still visible */}
|
|
{!evalApiReachable && evalRounds && evalRounds.length > 0 && (
|
|
<div className="mb-4 flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/40 px-3 py-2 text-xs text-gray-400">
|
|
<AlertCircle className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
|
|
Showing cached round list — live API had a transient error. Import may still work; retry if it fails.
|
|
</div>
|
|
)}
|
|
|
|
{/* New round check result */}
|
|
{evalCheckResult && (
|
|
<div className={`mb-4 rounded-lg border p-3 ${
|
|
evalCheckResult.error
|
|
? "border-red-500/30 bg-red-900/20"
|
|
: evalCheckResult.new_round_available
|
|
? "border-green-500/30 bg-green-900/20"
|
|
: "border-gray-700 bg-gray-800/50"
|
|
}`}>
|
|
{evalCheckResult.error ? (
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<XCircle className="h-4 w-4 flex-shrink-0 text-red-400 mt-0.5" />
|
|
<div>
|
|
<span className="text-red-400">
|
|
{evalCheckResult.error.includes("502") || evalCheckResult.error.includes("Cloudflare") || evalCheckResult.error.includes("origin")
|
|
? "MITRE Evaluations API temporarily unavailable (server overload). Try again in a few minutes."
|
|
: `Check failed: ${evalCheckResult.error}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : evalCheckResult.new_round_available ? (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<CheckCircle className="h-4 w-4 text-green-400 flex-shrink-0" />
|
|
<span className="text-green-400 font-medium">
|
|
New round available: {evalCheckResult.latest_round?.display_name} (Round {evalCheckResult.latest_round?.eval_round})
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<CalendarCheck className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
|
<span className="text-gray-400">
|
|
Up to date — latest round ({evalCheckResult.latest_round?.display_name}) is already imported.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk approve result */}
|
|
{bulkApproveResult && (
|
|
<div className="mb-4 rounded-lg border border-green-500/30 bg-green-900/20 p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<ShieldCheck className="h-4 w-4 text-green-400" />
|
|
<span className="text-sm font-medium text-green-400">Bulk approval complete</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-center text-sm">
|
|
<div>
|
|
<p className="text-xl font-bold text-white">{bulkApproveResult.approved}</p>
|
|
<p className="text-xs text-gray-400">Tests validated</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold text-white">{bulkApproveResult.techniques_recalculated}</p>
|
|
<p className="text-xs text-gray-400">Technique statuses updated</p>
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 text-xs text-gray-400">{bulkApproveResult.message}</p>
|
|
<button
|
|
onClick={() => setBulkApproveResult(null)}
|
|
className="mt-2 text-xs text-gray-500 hover:text-gray-400 underline"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Re-enrich result */}
|
|
{reEnrichResult && (
|
|
<div className="mb-4 rounded-lg border border-blue-500/30 bg-blue-900/20 p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Sparkles className="h-4 w-4 text-blue-400" />
|
|
<span className="text-sm font-medium text-blue-400">Re-enrichment complete</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-center text-sm">
|
|
<div>
|
|
<p className="text-xl font-bold text-white">{reEnrichResult.updated}</p>
|
|
<p className="text-xs text-gray-400">Tests enriched</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-blue-400 truncate">{reEnrichResult.adversary}</p>
|
|
<p className="text-xs text-gray-400">Round {reEnrichResult.eval_round}</p>
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 text-xs text-gray-400">{reEnrichResult.message}</p>
|
|
<button
|
|
onClick={() => setReEnrichResult(null)}
|
|
className="mt-2 text-xs text-gray-500 hover:text-gray-400 underline"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import result feedback */}
|
|
{evalImportResult && (
|
|
<div className="mb-4 rounded-lg border border-green-500/30 bg-green-900/20 p-4">
|
|
<div className="flex items-center gap-2 mb-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="grid grid-cols-3 gap-3 text-center text-sm">
|
|
<div>
|
|
<p className="text-xl font-bold text-white">{evalImportResult.created}</p>
|
|
<p className="text-xs text-gray-400">Tests created</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold text-white">{evalImportResult.techniques_covered}</p>
|
|
<p className="text-xs text-gray-400">Techniques covered</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-orange-400 truncate">{evalImportResult.adversary}</p>
|
|
<p className="text-xs text-gray-400">Round {evalImportResult.eval_round}</p>
|
|
</div>
|
|
</div>
|
|
<p className="mt-3 text-xs text-yellow-400 flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
All tests are in <strong className="ml-1">Review Queue</strong> — Blue Leads must validate before they count as coverage.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import error */}
|
|
{(importLatestMutation.isError || importRoundMutation.isError) && (
|
|
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<XCircle className="h-4 w-4 flex-shrink-0 text-red-400 mt-0.5" />
|
|
<div>
|
|
{(() => {
|
|
const msg = ((importLatestMutation.error || importRoundMutation.error) as Error)?.message ?? "";
|
|
if (msg.includes("502") || msg.includes("Cloudflare") || msg.includes("origin") || msg.includes("Connection")) {
|
|
return (
|
|
<span className="text-red-400">
|
|
MITRE Evaluations API is temporarily unavailable (server overload or maintenance).
|
|
The import fetches live results from evals.mitre.org — please try again in a few minutes.
|
|
</span>
|
|
);
|
|
}
|
|
if (msg.includes("already imported") || msg.includes("409")) {
|
|
return <span className="text-yellow-400">This round has already been imported.</span>;
|
|
}
|
|
return <span className="text-red-400">{msg || "Import failed. This round may already be imported."}</span>;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rounds table */}
|
|
<div>
|
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Available Rounds
|
|
</p>
|
|
{evalRoundsLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-orange-400" />
|
|
</div>
|
|
) : evalRounds && evalRounds.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">Round</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Adversary</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Imported</th>
|
|
<th className="pb-3 px-4 font-medium text-gray-400">Tests</th>
|
|
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{evalRounds.map((round) => (
|
|
<tr
|
|
key={round.name}
|
|
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
|
>
|
|
<td className="py-3 pr-4">
|
|
<span className="inline-flex items-center justify-center rounded-full border border-orange-500/30 bg-orange-900/20 px-2.5 py-0.5 text-xs font-bold text-orange-400">
|
|
R{round.eval_round}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<p className="font-medium text-gray-200">{round.display_name}</p>
|
|
<p className="text-xs text-gray-500 font-mono">{round.name}</p>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{round.imported ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-green-500/30 bg-green-900/20 px-2.5 py-0.5 text-xs font-medium text-green-400">
|
|
<CheckCircle className="h-3 w-3" />
|
|
Imported
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-gray-600 bg-gray-800/50 px-2.5 py-0.5 text-xs font-medium text-gray-400">
|
|
Not imported
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs text-gray-400">
|
|
{round.imported_at
|
|
? new Date(round.imported_at).toLocaleDateString("en-GB", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
})
|
|
: "—"}
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
{round.imported ? (
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-gray-200">{round.tests_created ?? "—"}</p>
|
|
<p className="text-xs text-gray-500">{round.techniques_covered ?? ""} techniques</p>
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-600">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 pl-4">
|
|
{round.imported ? (
|
|
<button
|
|
onClick={() => {
|
|
setReEnrichingRound(round.name);
|
|
reEnrichMutation.mutate({
|
|
adversary_name: round.name,
|
|
adversary_display: round.display_name,
|
|
eval_round: round.eval_round,
|
|
});
|
|
}}
|
|
disabled={reEnrichMutation.isPending || importRoundMutation.isPending}
|
|
className="flex items-center gap-1.5 rounded-lg border border-blue-500/30 bg-blue-900/20 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-900/40 disabled:opacity-50 transition-colors"
|
|
title="Update existing tests with attack path, criteria and data sources from MITRE API"
|
|
>
|
|
{reEnrichMutation.isPending && reEnrichingRound === round.name ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
)}
|
|
Re-enrich
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setEvalImportingRound(round.name);
|
|
importRoundMutation.mutate({
|
|
adversary_name: round.name,
|
|
adversary_display: round.display_name,
|
|
eval_round: round.eval_round,
|
|
});
|
|
}}
|
|
disabled={
|
|
importLatestMutation.isPending ||
|
|
importRoundMutation.isPending
|
|
}
|
|
className="flex items-center gap-1.5 rounded-lg border border-orange-500/30 bg-orange-900/20 px-3 py-1.5 text-xs font-medium text-orange-400 hover:bg-orange-900/40 disabled:opacity-50 transition-colors"
|
|
>
|
|
{importRoundMutation.isPending && evalImportingRound === round.name ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Download className="h-3.5 w-3.5" />
|
|
)}
|
|
Import
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center">
|
|
<p className="text-sm text-gray-500">No evaluation rounds available.</p>
|
|
{evalApiError && (
|
|
<p className="mt-1 text-xs text-orange-400/70 font-mono">{evalApiError}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Bulk Approve Modal ──────────────────────────────────────── */}
|
|
{showBulkApproveModal && (
|
|
<BulkApproveModal
|
|
pendingCount={evalPendingData?.pending ?? 0}
|
|
isPending={bulkApproveMutation.isPending}
|
|
error={bulkApproveMutation.isError ? ((bulkApproveMutation.error as Error)?.message ?? "Unknown error") : null}
|
|
onConfirm={() => bulkApproveMutation.mutate()}
|
|
onClose={() => setShowBulkApproveModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* ────────────────────────────────────────────────────────────────
|
|
TEMPLATE ADMINISTRATION (T-124)
|
|
──────────────────────────────────────────────────────────────── */}
|
|
|
|
{/* Template Catalog Stats */}
|
|
<div className="grid gap-6 lg:grid-cols-1">
|
|
{/* 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}
|
|
/>
|
|
)}
|
|
|
|
{/* Bulk Activate Confirmation Modal */}
|
|
{bulkConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-xl">
|
|
<h3 className="text-lg font-semibold text-white">
|
|
{bulkConfirm === "activate" ? "Activate All Templates" : "Deactivate All Templates"}
|
|
</h3>
|
|
<p className="mt-2 text-sm text-gray-400">
|
|
{bulkConfirm === "activate"
|
|
? "This will activate ALL templates in the catalog, including previously deactivated ones. All templates will become available for test creation."
|
|
: "This will deactivate ALL templates in the catalog. No templates will be available for test creation until reactivated."}
|
|
</p>
|
|
<p className="mt-2 text-sm font-medium text-yellow-400">
|
|
This action affects all {templateStats?.total || 0} templates.
|
|
</p>
|
|
<div className="mt-4 flex items-center justify-end gap-3">
|
|
<button
|
|
onClick={() => setBulkConfirm(null)}
|
|
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>
|
|
<button
|
|
onClick={() => bulkActivateMutation.mutate(bulkConfirm === "activate")}
|
|
disabled={bulkActivateMutation.isPending}
|
|
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${
|
|
bulkConfirm === "activate"
|
|
? "bg-green-600 hover:bg-green-500"
|
|
: "bg-red-600 hover:bg-red-500"
|
|
}`}
|
|
>
|
|
{bulkActivateMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : bulkConfirm === "activate" ? (
|
|
<ToggleRight className="h-4 w-4" />
|
|
) : (
|
|
<ToggleLeft className="h-4 w-4" />
|
|
)}
|
|
{bulkActivateMutation.isPending
|
|
? "Processing..."
|
|
: bulkConfirm === "activate"
|
|
? "Activate All"
|
|
: "Deactivate All"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Templates Management Table */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
|
<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>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setBulkConfirm("activate")}
|
|
className="flex items-center gap-1.5 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-2 text-sm font-medium text-green-400 hover:bg-green-900/40 transition-colors"
|
|
>
|
|
<ToggleRight className="h-4 w-4" />
|
|
Activate All
|
|
</button>
|
|
<button
|
|
onClick={() => setBulkConfirm("deactivate")}
|
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
|
>
|
|
<ToggleLeft className="h-4 w-4" />
|
|
Deactivate All
|
|
</button>
|
|
<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
|
|
</button>
|
|
</div>
|
|
</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">
|
|
<button
|
|
onClick={() => setSelectedTemplateId(tpl.id)}
|
|
className="text-left font-medium text-cyan-400 hover:text-cyan-300 hover:underline truncate block max-w-[200px] transition-colors"
|
|
title="Click to view/edit"
|
|
>
|
|
{tpl.name}
|
|
</button>
|
|
</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-red-400 hover:text-red-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>
|
|
|
|
{/* Scheduled Jobs */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-4 text-lg font-semibold text-white">Scheduled Jobs</h2>
|
|
{statusLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : statusError ? (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-red-400">
|
|
<AlertCircle className="h-5 w-5" />
|
|
<span>Failed to load scheduler status</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">Job ID</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">Next Run</th>
|
|
<th className="pb-3 pl-4 font-medium text-gray-400">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{schedulerStatus?.jobs.map((job) => (
|
|
<tr
|
|
key={job.id}
|
|
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
|
>
|
|
<td className="py-3 pr-4">
|
|
<code className="rounded bg-gray-800 px-2 py-0.5 text-xs text-cyan-400">
|
|
{job.id}
|
|
</code>
|
|
</td>
|
|
<td className="py-3 px-4 text-gray-200">{job.name}</td>
|
|
<td className="py-3 px-4 text-gray-400">
|
|
{formatNextRun(job.next_run_time)}
|
|
</td>
|
|
<td className="py-3 pl-4">
|
|
{job.next_run_time ? (
|
|
<span className="inline-flex items-center gap-1 text-green-400">
|
|
<CheckCircle className="h-3.5 w-3.5" />
|
|
Scheduled
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-yellow-400">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
Pending
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{(!schedulerStatus?.jobs || schedulerStatus.jobs.length === 0) && (
|
|
<tr>
|
|
<td colSpan={4} className="py-8 text-center text-gray-400">
|
|
No scheduled jobs found
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Template Detail Modal */}
|
|
{selectedTemplateId && (
|
|
selectedTemplateLoading ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : selectedTemplate ? (
|
|
<TemplateDetailModal
|
|
template={selectedTemplate}
|
|
onClose={() => setSelectedTemplateId(null)}
|
|
onSave={(id, payload) => updateTemplateMutation.mutate({ id, payload })}
|
|
onToggleActive={(id) => toggleActiveMutation.mutate(id)}
|
|
isSaving={updateTemplateMutation.isPending}
|
|
isToggling={toggleActiveMutation.isPending}
|
|
/>
|
|
) : null
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Bulk Approve Evaluation Tests Modal ─────────────────────────── */
|
|
|
|
function BulkApproveModal({
|
|
pendingCount,
|
|
isPending,
|
|
error,
|
|
onConfirm,
|
|
onClose,
|
|
}: {
|
|
pendingCount: number;
|
|
isPending: boolean;
|
|
error: string | null;
|
|
onConfirm: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [checks, setChecks] = useState({
|
|
labEnv: false,
|
|
notOrg: false,
|
|
metricsImpact: false,
|
|
spotCheck: false,
|
|
});
|
|
|
|
const allChecked = Object.values(checks).every(Boolean);
|
|
|
|
const toggle = (key: keyof typeof checks) =>
|
|
setChecks((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
|
<div className="w-full max-w-lg rounded-xl border border-orange-500/40 bg-gray-900 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-lg bg-orange-500/10 p-2">
|
|
<ShieldCheck className="h-5 w-5 text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-base font-semibold text-white">Bulk Approve Evaluation Tests</h3>
|
|
<p className="text-xs text-gray-400">
|
|
{pendingCount} test{pendingCount !== 1 ? "s" : ""} awaiting Blue Team approval
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-5 space-y-4">
|
|
{/* What will happen */}
|
|
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-sm text-gray-300">
|
|
<p className="font-medium text-white mb-1">What this action does:</p>
|
|
<ul className="space-y-1 text-xs text-gray-400 list-disc list-inside">
|
|
<li>Approves the Blue Team validation for <strong className="text-white">{pendingCount} imported evaluation tests</strong></li>
|
|
<li>Moves all of them from <span className="text-yellow-400">In Review</span> → <span className="text-green-400">Validated</span></li>
|
|
<li>Recalculates technique coverage across the ATT&CK matrix immediately</li>
|
|
<li>Updates programme score, dashboards, and executive metrics</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Warnings — must all be checked */}
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Read and confirm each point before proceeding
|
|
</p>
|
|
|
|
<label
|
|
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
|
checks.labEnv ? "border-orange-500/50 bg-orange-900/10" : "border-gray-700 hover:border-gray-600"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checks.labEnv}
|
|
onChange={() => toggle("labEnv")}
|
|
className="mt-0.5 h-4 w-4 accent-orange-500 flex-shrink-0"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
<strong className="text-orange-400">Lab environment data.</strong>{" "}
|
|
These results come from a controlled MITRE Engenuity evaluation against simulated adversaries —
|
|
not from live attacks on your organisation's infrastructure.
|
|
</span>
|
|
</label>
|
|
|
|
<label
|
|
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
|
checks.notOrg ? "border-orange-500/50 bg-orange-900/10" : "border-gray-700 hover:border-gray-600"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checks.notOrg}
|
|
onChange={() => toggle("notOrg")}
|
|
className="mt-0.5 h-4 w-4 accent-orange-500 flex-shrink-0"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
<strong className="text-orange-400">Not your organisation's actual detection.</strong>{" "}
|
|
CrowdStrike Falcon's lab performance does <em>not</em> guarantee the same detection
|
|
capability in your specific deployment, configuration, and environment.
|
|
</span>
|
|
</label>
|
|
|
|
<label
|
|
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
|
checks.metricsImpact ? "border-yellow-500/50 bg-yellow-900/10" : "border-gray-700 hover:border-gray-600"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checks.metricsImpact}
|
|
onChange={() => toggle("metricsImpact")}
|
|
className="mt-0.5 h-4 w-4 accent-yellow-500 flex-shrink-0"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
<strong className="text-yellow-400">Coverage metrics will change.</strong>{" "}
|
|
Approving these tests will immediately raise the ATT&CK coverage percentage
|
|
and programme score. Make sure stakeholders understand these are baseline evaluation
|
|
results before sharing reports.
|
|
</span>
|
|
</label>
|
|
|
|
<label
|
|
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
|
checks.spotCheck ? "border-blue-500/50 bg-blue-900/10" : "border-gray-700 hover:border-gray-600"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checks.spotCheck}
|
|
onChange={() => toggle("spotCheck")}
|
|
className="mt-0.5 h-4 w-4 accent-blue-500 flex-shrink-0"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
<strong className="text-blue-400">Spot-checking recommended.</strong>{" "}
|
|
I understand that Blue Leads should individually validate high-priority techniques
|
|
(e.g. T1059, T1078, T1003) in the actual environment before trusting bulk approval
|
|
for those techniques.
|
|
</span>
|
|
</label>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
|
|
<button
|
|
onClick={onClose}
|
|
disabled={isPending}
|
|
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={!allChecked || isPending}
|
|
className="flex items-center gap-2 rounded-lg bg-green-700 px-5 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<ShieldCheck className="h-4 w-4" />
|
|
)}
|
|
{isPending
|
|
? `Approving ${pendingCount} tests…`
|
|
: allChecked
|
|
? `Approve ${pendingCount} Tests`
|
|
: "Confirm all 4 points above"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
|
|
/* ── Template Detail / Edit Modal ────────────────────────────────── */
|
|
|
|
function TemplateDetailModal({
|
|
template,
|
|
onClose,
|
|
onSave,
|
|
onToggleActive,
|
|
isSaving,
|
|
isToggling,
|
|
}: {
|
|
template: TestTemplate;
|
|
onClose: () => void;
|
|
onSave: (id: string, payload: Partial<CreateTemplatePayload>) => void;
|
|
onToggleActive: (id: string) => void;
|
|
isSaving: boolean;
|
|
isToggling: boolean;
|
|
}) {
|
|
const [form, setForm] = useState<Partial<CreateTemplatePayload>>({
|
|
name: template.name,
|
|
description: template.description ?? "",
|
|
attack_procedure: template.attack_procedure ?? "",
|
|
expected_detection: template.expected_detection ?? "",
|
|
platform: template.platform ?? "",
|
|
tool_suggested: template.tool_suggested ?? "",
|
|
severity: template.severity ?? "",
|
|
mitre_technique_id: template.mitre_technique_id,
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
onSave(template.id, form);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
|
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">Edit Template</h2>
|
|
<p className="mt-0.5 text-xs text-gray-400 font-mono">{template.mitre_technique_id}</p>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Meta badges */}
|
|
<div className="flex flex-wrap gap-2 px-6 py-3 border-b border-gray-800">
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
template.source === "atomic_red_team"
|
|
? "bg-red-900/50 text-red-400 border-red-500/30"
|
|
: template.source === "mitre"
|
|
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
|
|
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
|
}`}
|
|
>
|
|
{template.source.replace(/_/g, " ")}
|
|
</span>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
template.is_active
|
|
? "bg-green-900/50 text-green-400 border-green-500/30"
|
|
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
|
|
}`}
|
|
>
|
|
{template.is_active ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<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 })}
|
|
required
|
|
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"
|
|
/>
|
|
</div>
|
|
<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 })}
|
|
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"
|
|
/>
|
|
</div>
|
|
<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="">None</option>
|
|
<option value="windows">Windows</option>
|
|
<option value="linux">Linux</option>
|
|
<option value="macos">macOS</option>
|
|
<option value="cloud">Cloud</option>
|
|
</select>
|
|
</div>
|
|
<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="">None</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="critical">Critical</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<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 })}
|
|
rows={2}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
<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 })}
|
|
rows={3}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
<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 })}
|
|
rows={2}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
<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 })}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-3 pt-2 border-t border-gray-800">
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
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"
|
|
>
|
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
|
{isSaving ? "Saving..." : "Save Changes"}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggleActive(template.id)}
|
|
disabled={isToggling}
|
|
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
|
|
template.is_active
|
|
? "border-red-500/30 bg-red-900/20 text-red-400 hover:bg-red-900/40"
|
|
: "border-green-500/30 bg-green-900/20 text-green-400 hover:bg-green-900/40"
|
|
}`}
|
|
>
|
|
{isToggling ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : template.is_active ? (
|
|
<ToggleRight className="h-4 w-4" />
|
|
) : (
|
|
<ToggleLeft className="h-4 w-4" />
|
|
)}
|
|
{template.is_active ? "Deactivate" : "Activate"}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="ml-auto 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"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|