feat: Phase 8 - Frontend main views (T-026 to T-031)

Implement all main frontend views for the MITRE ATT&CK coverage platform:

- T-026: Dashboard with coverage summary cards and tactic breakdown table

- T-027: Interactive ATT&CK matrix with filtering by status, tactic, platform

- T-028: Technique detail page with tests, intel items, and review actions

- T-029: Test creation form with technique selector and validation

- T-030: Test detail page with drag and drop evidence upload and download

- T-031: System admin panel with MITRE sync and intel scan controls

New components: CoverageSummaryCard, TacticCoverageChart, AttackMatrix, TechniqueCell, TestForm, EvidenceUpload, EvidenceList

New API modules: metrics.ts, techniques.ts, tests.ts, evidence.ts, system.ts

All views use TanStack Query for data fetching with proper loading and error states. Role-based UI controls for admin/lead actions.
This commit is contained in:
2026-02-06 16:21:14 +01:00
parent 591b5df250
commit cb447f3803
22 changed files with 3092 additions and 27 deletions

View File

@@ -0,0 +1,258 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { getTechniques, type TechniqueSummary } from "../api/techniques";
import type { TestResult } from "../types/models";
export interface TestFormData {
technique_id: string;
name: string;
description: string;
platform: string;
procedure_text: string;
tool_used: string;
result?: TestResult;
}
interface TestFormProps {
initialData?: Partial<TestFormData>;
preselectedTechniqueId?: string;
onSubmit: (data: TestFormData) => void;
isSubmitting: boolean;
showResult?: boolean;
}
const PLATFORMS = [
{ value: "", label: "Select platform" },
{ value: "windows", label: "Windows" },
{ value: "linux", label: "Linux" },
{ value: "macos", label: "macOS" },
{ value: "cloud", label: "Cloud" },
{ value: "network", label: "Network" },
];
const RESULTS: { value: TestResult | ""; label: string }[] = [
{ value: "", label: "Select result (optional)" },
{ value: "detected", label: "Detected" },
{ value: "not_detected", label: "Not Detected" },
{ value: "partially_detected", label: "Partially Detected" },
];
export default function TestForm({
initialData,
preselectedTechniqueId,
onSubmit,
isSubmitting,
showResult = false,
}: TestFormProps) {
const [formData, setFormData] = useState<TestFormData>({
technique_id: preselectedTechniqueId || initialData?.technique_id || "",
name: initialData?.name || "",
description: initialData?.description || "",
platform: initialData?.platform || "",
procedure_text: initialData?.procedure_text || "",
tool_used: initialData?.tool_used || "",
result: initialData?.result,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: techniques, isLoading: techniquesLoading } = useQuery({
queryKey: ["techniques"],
queryFn: () => getTechniques(),
});
// Update technique_id when preselected changes
useEffect(() => {
if (preselectedTechniqueId) {
setFormData((prev) => ({ ...prev, technique_id: preselectedTechniqueId }));
}
}, [preselectedTechniqueId]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.technique_id) {
newErrors.technique_id = "Technique is required";
}
if (!formData.name.trim()) {
newErrors.name = "Name is required";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
onSubmit({
...formData,
result: formData.result || undefined,
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Technique Selector */}
<div>
<label htmlFor="technique_id" className="block text-sm font-medium text-gray-300">
Technique *
</label>
<select
id="technique_id"
name="technique_id"
value={formData.technique_id}
onChange={handleChange}
disabled={!!preselectedTechniqueId || techniquesLoading}
className={`mt-1 block w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500 ${
errors.technique_id ? "border-red-500" : "border-gray-700"
} ${preselectedTechniqueId ? "opacity-70" : ""}`}
>
<option value="">Select a technique</option>
{techniques?.map((tech: TechniqueSummary) => (
<option key={tech.id} value={tech.id}>
{tech.mitre_id} - {tech.name}
</option>
))}
</select>
{errors.technique_id && (
<p className="mt-1 text-sm text-red-400">{errors.technique_id}</p>
)}
</div>
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Enter test name"
className={`mt-1 block w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 ${
errors.name ? "border-red-500" : "border-gray-700"
}`}
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
placeholder="Describe the test objective and scope"
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
{/* Platform */}
<div>
<label htmlFor="platform" className="block text-sm font-medium text-gray-300">
Platform
</label>
<select
id="platform"
name="platform"
value={formData.platform}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
>
{PLATFORMS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
{/* Procedure */}
<div>
<label htmlFor="procedure_text" className="block text-sm font-medium text-gray-300">
Procedure
</label>
<textarea
id="procedure_text"
name="procedure_text"
value={formData.procedure_text}
onChange={handleChange}
rows={5}
placeholder="Step-by-step procedure for executing this test"
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
{/* Tool Used */}
<div>
<label htmlFor="tool_used" className="block text-sm font-medium text-gray-300">
Tool Used
</label>
<input
type="text"
id="tool_used"
name="tool_used"
value={formData.tool_used}
onChange={handleChange}
placeholder="e.g., Atomic Red Team, Cobalt Strike, etc."
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
{/* Result (optional, for editing) */}
{showResult && (
<div>
<label htmlFor="result" className="block text-sm font-medium text-gray-300">
Result
</label>
<select
id="result"
name="result"
value={formData.result || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
>
{RESULTS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
)}
{/* Submit Button */}
<div className="flex justify-end gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-6 py-2.5 font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{isSubmitting ? "Creating..." : "Create Test"}
</button>
</div>
</form>
);
}