diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 053bb33..4452d8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,6 @@ import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; /* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */ -const TechniquesPage = React.lazy(() => import("./pages/TechniquesPage")); const MatrixPage = React.lazy(() => import("./pages/MatrixPage")); const ExecutiveDashboardPage = React.lazy(() => import("./pages/ExecutiveDashboardPage")); const CompliancePage = React.lazy(() => import("./pages/CompliancePage")); @@ -50,7 +49,8 @@ export default function App() { {/* ── Core ─────────────────────────────────────────────── */} } /> - }>} /> + {/* Techniques was merged into the Coverage Matrix's "Coverage" layer + List view */} + } /> }>} /> }>} /> diff --git a/frontend/src/api/heatmap.ts b/frontend/src/api/heatmap.ts index f75c885..58679a3 100644 --- a/frontend/src/api/heatmap.ts +++ b/frontend/src/api/heatmap.ts @@ -9,6 +9,10 @@ export interface HeatmapMetadata { export interface HeatmapTechnique { techniqueID: string; + /** Technique display name — always present now, shown inline on cells (matches the Techniques page's style). */ + name?: string; + /** Real TechniqueStatus, when the layer has one (coverage, threat-actor) — used to color cells the same way the Techniques page does. Absent for detection-rules/campaign layers. */ + status?: string; tactic: string; color: string; score: number; diff --git a/frontend/src/components/AttackMatrix.tsx b/frontend/src/components/AttackMatrix.tsx deleted file mode 100644 index b2ab6c1..0000000 --- a/frontend/src/components/AttackMatrix.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useMemo } from "react"; -import TechniqueCell from "./TechniqueCell"; -import type { TechniqueSummary } from "../api/techniques"; - -interface AttackMatrixProps { - techniques: TechniqueSummary[]; -} - -// MITRE ATT&CK Enterprise Tactics in order -const TACTIC_ORDER = [ - "reconnaissance", - "resource-development", - "initial-access", - "execution", - "persistence", - "privilege-escalation", - "defense-evasion", - "credential-access", - "discovery", - "lateral-movement", - "collection", - "command-and-control", - "exfiltration", - "impact", -]; - -const formatTacticName = (tactic: string): string => { - return tactic - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -}; - -export default function AttackMatrix({ techniques }: AttackMatrixProps) { - // Group techniques by tactic - const groupedByTactic = useMemo(() => { - const groups: Record = {}; - - for (const tech of techniques) { - // A technique can belong to multiple tactics (comma-separated) - const tactics = tech.tactic - ? tech.tactic.split(",").map((t) => t.trim().toLowerCase()) - : ["unknown"]; - - for (const tactic of tactics) { - if (!groups[tactic]) { - groups[tactic] = []; - } - groups[tactic].push(tech); - } - } - - // Sort techniques within each tactic by mitre_id - for (const tactic of Object.keys(groups)) { - groups[tactic].sort((a, b) => a.mitre_id.localeCompare(b.mitre_id)); - } - - return groups; - }, [techniques]); - - // Get ordered tactics that have techniques - const orderedTactics = useMemo(() => { - const tacticSet = new Set(Object.keys(groupedByTactic)); - const ordered = TACTIC_ORDER.filter((t) => tacticSet.has(t)); - // Add any unknown tactics at the end - const remaining = Array.from(tacticSet).filter((t) => !TACTIC_ORDER.includes(t)); - return [...ordered, ...remaining]; - }, [groupedByTactic]); - - if (techniques.length === 0) { - return ( -
-

No techniques found matching your filters

-
- ); - } - - return ( -
-
-
- {orderedTactics.map((tactic) => ( -
- {/* Tactic header */} -
-

- {formatTacticName(tactic)} -

-

- {groupedByTactic[tactic]?.length || 0} techniques -

-
- - {/* Technique cells */} -
- {groupedByTactic[tactic]?.map((tech) => ( - - ))} -
-
- ))} -
-
-
- ); -} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7cd765b..958e5ae 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,7 +16,6 @@ import { Crosshair, Zap, Grid3X3, - List, Gauge, ShieldCheck, GitCompareArrows, @@ -44,7 +43,6 @@ const mainLinks: NavItem[] = [ label: "ATT&CK", icon: Grid3X3, children: [ - { to: "/techniques", label: "Techniques", icon: List }, { to: "/matrix", label: "Coverage Matrix", icon: Grid3X3 }, { to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] }, ], diff --git a/frontend/src/components/TechniqueCell.tsx b/frontend/src/components/TechniqueCell.tsx deleted file mode 100644 index aa8b129..0000000 --- a/frontend/src/components/TechniqueCell.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Link } from "react-router-dom"; -import { AlertTriangle } from "lucide-react"; -import type { TechniqueStatus } from "../types/models"; - -interface TechniqueCellProps { - mitreId: string; - name: string; - status: TechniqueStatus; - reviewRequired?: boolean; -} - -const statusColors: Record = { - validated: { - bg: "bg-green-900/40", - border: "border-green-500/50", - text: "text-green-400", - }, - partial: { - bg: "bg-yellow-900/40", - border: "border-yellow-500/50", - text: "text-yellow-400", - }, - in_progress: { - bg: "bg-blue-900/40", - border: "border-blue-500/50", - text: "text-blue-400", - }, - not_covered: { - bg: "bg-red-900/40", - border: "border-red-500/50", - text: "text-red-400", - }, - not_evaluated: { - bg: "bg-gray-800/40", - border: "border-gray-600/50", - text: "text-gray-400", - }, - review_required: { - bg: "bg-yellow-900/40", - border: "border-yellow-500/50", - text: "text-yellow-400", - }, -}; - -export default function TechniqueCell({ - mitreId, - name, - status, - reviewRequired = false, -}: TechniqueCellProps) { - const colors = statusColors[status] || statusColors.not_evaluated; - - return ( - - {reviewRequired && ( -
- -
- )} -

{mitreId}

-

- {name} -

- - ); -} diff --git a/frontend/src/components/heatmap/AdvancedHeatmap.tsx b/frontend/src/components/heatmap/AdvancedHeatmap.tsx index 89075a7..67a2073 100644 --- a/frontend/src/components/heatmap/AdvancedHeatmap.tsx +++ b/frontend/src/components/heatmap/AdvancedHeatmap.tsx @@ -47,7 +47,9 @@ function TacticColumn({ }) { const parentRef = useRef(null); - const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 40 : 60; + // normal/expanded now show the technique name under the ID (matching the + // Techniques page's cell style), so rows need enough height for 2 lines. + const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 48 : 64; const rowVirtualizer = useVirtualizer({ count: techniques.length, diff --git a/frontend/src/components/heatmap/HeatmapCell.tsx b/frontend/src/components/heatmap/HeatmapCell.tsx index 66f2654..ae4a9cb 100644 --- a/frontend/src/components/heatmap/HeatmapCell.tsx +++ b/frontend/src/components/heatmap/HeatmapCell.tsx @@ -1,5 +1,7 @@ import React, { useState, useMemo, useCallback } from "react"; +import { AlertTriangle } from "lucide-react"; import type { HeatmapTechnique } from "../../api/heatmap"; +import { cellColorsFor } from "./cellColors"; import HeatmapTooltip from "./HeatmapTooltip"; interface HeatmapCellProps { @@ -12,45 +14,24 @@ interface HeatmapCellProps { * Memoized heatmap cell — this component renders 3000+ times in the * full ATT&CK matrix, so React.memo prevents unnecessary re-renders * when only sibling cells change. + * + * Styled to match TechniqueCell (the old standalone Techniques page's + * cell): discrete status-colored Tailwind classes instead of an inline + * hex background, and the technique name shown inline instead of only + * via hover tooltip. */ const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) { const [showTooltip, setShowTooltip] = useState(false); - const sizeClasses = { - compact: "h-6 text-[9px] px-1", - normal: "h-9 text-[11px] px-1.5", - expanded: "h-14 text-xs px-2", - }; - - const bgColor = technique.enabled ? technique.color : "transparent"; const isDisabled = !technique.enabled; - - // Memoize text color (derived from background hex) - const textColor = useMemo(() => { - const hex = bgColor; - if (!hex || hex === "transparent" || hex === "") return "text-gray-600"; - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - return brightness > 128 ? "text-gray-900" : "text-white"; - }, [bgColor]); - - // Status indicators — memoized - const { testsCount, reviewRequired, isValidated } = useMemo(() => { - const hasTests = technique.metadata.find((m) => m.name === "tests_count"); - return { - testsCount: hasTests ? parseInt(hasTests.value, 10) : 0, - reviewRequired: technique.comment?.toLowerCase().includes("review") ?? false, - isValidated: technique.score >= 100, - }; - }, [technique.metadata, technique.comment, technique.score]); + const colors = useMemo(() => cellColorsFor(technique), [technique]); + const reviewRequired = technique.status === "review_required"; const handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]); return (
setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} > @@ -58,28 +39,25 @@ const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick } onClick={handleClick} disabled={isDisabled} className={` - w-full rounded border transition-all duration-150 - ${sizeClasses[size]} + relative block h-full w-full rounded-md border p-1.5 text-left transition-all overflow-hidden ${isDisabled ? "cursor-default border-gray-800/30 bg-gray-900/20 opacity-30" - : "cursor-pointer border-gray-700/50 hover:brightness-110 hover:ring-1 hover:ring-cyan-400/40" + : `cursor-pointer hover:scale-[1.02] hover:shadow-lg hover:z-10 ${colors.bg} ${colors.border}` } - ${reviewRequired && !isDisabled ? "ring-1 ring-amber-400/60" : ""} - flex items-center gap-1 overflow-hidden `} - style={{ - backgroundColor: isDisabled ? undefined : bgColor, - }} > - + {reviewRequired && !isDisabled && ( +
+ +
+ )} +

{technique.techniqueID} - - {size !== "compact" && !isDisabled && ( - - {testsCount === 0 && 🔴} - {reviewRequired && ⚠️} - {isValidated && } - +

+ {size !== "compact" && ( +

+ {technique.name} +

)} diff --git a/frontend/src/components/heatmap/HeatmapLegend.tsx b/frontend/src/components/heatmap/HeatmapLegend.tsx index d28b32b..fe94c6f 100644 --- a/frontend/src/components/heatmap/HeatmapLegend.tsx +++ b/frontend/src/components/heatmap/HeatmapLegend.tsx @@ -1,5 +1,7 @@ +import { AlertTriangle } from "lucide-react"; import type { TechniqueStatus } from "../../types/models"; import { TOOLTIPS } from "../StatusBadge"; +import { STATUS_CELL_COLORS, type CellColors } from "./cellColors"; interface HeatmapLegendProps { layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign"; @@ -10,47 +12,61 @@ function tooltipText(status: TechniqueStatus): string { return `${t.heading} — ${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`; } -const LEGENDS: Record< - string, - { label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] } -> = { +interface LegendItem { + colors: CellColors; + label: string; + description: string; + status?: TechniqueStatus; +} + +// Same 5-tier score buckets the cells fall back to for layers with no real +// TechniqueStatus (detection-rules, campaign) — see cellColors.ts. +const BUCKET_COLORS: CellColors[] = [ + { bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" }, + { bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" }, + { bg: "bg-orange-900/40", border: "border-orange-500/50", text: "text-orange-400" }, + { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" }, + { bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" }, +]; + +const LEGENDS: Record = { coverage: { - label: "Coverage Status", - colors: [ - { color: "#d3d3d3", label: "Not Evaluated (0)", status: "not_evaluated" }, - { color: "#ff6666", label: "Not Covered (10)", status: "not_covered" }, - { color: "#ff9933", label: "In Progress (30)", status: "in_progress" }, - { color: "#ffff66", label: "Partial (60)", status: "partial" }, - { color: "#66ff66", label: "Validated (100)", status: "validated" }, + label: "Coverage legend", + items: [ + { colors: STATUS_CELL_COLORS.validated, label: "Validated", description: "Detected in ≥1 approved test", status: "validated" }, + { colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" }, + { colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" }, + { colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Tested but not detected — confirmed gap", status: "not_covered" }, + { colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "No tests created yet — unknown risk", status: "not_evaluated" }, ], }, "threat-actor": { - label: "Threat Actor Coverage", - colors: [ - { color: "#d3d3d3", label: "Not Used by Actor" }, - { color: "#ff6666", label: "Not Covered (10)" }, - { color: "#ff9933", label: "In Progress (30)" }, - { color: "#ffff66", label: "Partial (60)" }, - { color: "#66ff66", label: "Covered (100)" }, + label: "Threat actor coverage legend", + items: [ + { colors: STATUS_CELL_COLORS.validated, label: "Covered", description: "Detected in ≥1 approved test", status: "validated" }, + { colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" }, + { colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" }, + { colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Used by this actor but not detected", status: "not_covered" }, + { colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "Used by this actor, no tests yet", status: "not_evaluated" }, ], }, "detection-rules": { - label: "Detection Rules Coverage", - colors: [ - { color: "#d3d3d3", label: "0 rules" }, - { color: "#ff6666", label: "1 rule" }, - { color: "#ff9933", label: "2 rules" }, - { color: "#ffff66", label: "3 rules" }, - { color: "#66ff66", label: "4+ rules" }, + label: "Detection rules legend", + items: [ + { colors: BUCKET_COLORS[0], label: "0 rules", description: "No detection rules cataloged" }, + { colors: BUCKET_COLORS[1], label: "1 rule", description: "Minimal detection coverage" }, + { colors: BUCKET_COLORS[2], label: "2 rules", description: "Partial detection coverage" }, + { colors: BUCKET_COLORS[3], label: "3 rules", description: "Good detection coverage" }, + { colors: BUCKET_COLORS[4], label: "4+ rules", description: "Strong detection coverage" }, ], }, campaign: { - label: "Campaign Progress", - colors: [ - { color: "#ff6666", label: "Draft / Rejected" }, - { color: "#ff9933", label: "Red Executing (30)" }, - { color: "#ffff66", label: "Blue Evaluating (50)" }, - { color: "#66ff66", label: "Validated (100)" }, + label: "Campaign progress legend", + items: [ + { colors: BUCKET_COLORS[1], label: "Draft / Rejected", description: "Not yet started or sent back" }, + { colors: BUCKET_COLORS[2], label: "Red Executing", description: "Red Team actively working" }, + { colors: BUCKET_COLORS[3], label: "Blue Evaluating", description: "Blue Team actively working" }, + { colors: BUCKET_COLORS[4], label: "Validated", description: "Completed and validated" }, ], }, }; @@ -59,42 +75,40 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) { const legend = LEGENDS[layerType] || LEGENDS.coverage; return ( -
- {legend.label}: - - {/* Gradient bar */} -
-
c.color).join(", ")})`, - }} - /> -
- - {/* Individual labels */} - {legend.colors.map((item) => ( -
+
+

{legend.label}

+
+ {legend.items.map((item) => (
- {item.label} -
- ))} + key={item.label} + className="flex items-start gap-2.5" + title={item.status ? tooltipText(item.status) : undefined} + > +
+
+

{item.label}

+

{item.description}

+
+
+ ))} - {/* Review Required — an overlay indicator (amber ring + ⚠️), not a - distinct score tier, so it's shown separately from the gradient. */} - {layerType === "coverage" && ( -
-
- ⚠️ Review Required (any status) -
- )} + {/* Review Required — an overlay indicator (⚠️ badge), not a + distinct tier, so it's shown separately for layers that carry + a real technique status. */} + {(layerType === "coverage" || layerType === "threat-actor") && ( +
+
+
+ +
+
+
+

Review Required

+

Pending lead validation or re-evaluation

+
+
+ )} +
); } diff --git a/frontend/src/components/heatmap/HeatmapTechniqueTable.tsx b/frontend/src/components/heatmap/HeatmapTechniqueTable.tsx new file mode 100644 index 0000000..21e7fef --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapTechniqueTable.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router-dom"; +import type { HeatmapTechnique } from "../../api/heatmap"; +import type { TechniqueStatus } from "../../types/models"; +import StatusBadge from "../StatusBadge"; + +interface Props { + techniques: HeatmapTechnique[]; +} + +/** Flat table alternative to the matrix grid — mirrors the old Techniques page's list view. */ +export default function HeatmapTechniqueTable({ techniques }: Props) { + const navigate = useNavigate(); + + return ( +
+ + + + + + + + + + + {techniques.map((tech) => ( + navigate(`/techniques/${tech.techniqueID}`)} + className="cursor-pointer border-b border-gray-800/50 transition-colors hover:bg-gray-800/30" + > + + + + + + ))} + {techniques.length === 0 && ( + + + + )} + +
MITRE IDNameTacticStatus
{tech.techniqueID}{tech.name || "—"} + {(tech.tactic || "—").replace(/-/g, " ")} + + {tech.status ? ( + + ) : ( + {tech.comment} + )} +
+ No techniques match the current filters. +
+
+ ); +} diff --git a/frontend/src/components/heatmap/cellColors.ts b/frontend/src/components/heatmap/cellColors.ts new file mode 100644 index 0000000..925d776 --- /dev/null +++ b/frontend/src/components/heatmap/cellColors.ts @@ -0,0 +1,52 @@ +import type { TechniqueStatus } from "../../types/models"; + +export interface CellColors { + bg: string; + border: string; + text: string; +} + +/** + * Discrete status palette — matches the Techniques page's cell colors + * exactly, so a technique looks the same whether viewed via the Coverage + * Matrix or (previously) the standalone Techniques page. + */ +export const STATUS_CELL_COLORS: Record = { + validated: { bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" }, + partial: { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" }, + in_progress: { bg: "bg-blue-900/40", border: "border-blue-500/50", text: "text-blue-400" }, + not_covered: { bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" }, + not_evaluated: { bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" }, + review_required: { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" }, +}; + +/** + * 5-tier fallback palette for heatmap layers with no real TechniqueStatus + * concept (detection-rules, campaign) — same score boundaries the backend + * uses for its hex gradient (_score_to_color), but as discrete Tailwind + * classes instead of an inline hex color. + */ +const SCORE_BUCKET_COLORS: CellColors[] = [ + { bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" }, + { bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" }, + { bg: "bg-orange-900/40", border: "border-orange-500/50", text: "text-orange-400" }, + { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" }, + { bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" }, +]; + +function scoreToBucketColors(score: number): CellColors { + if (score <= 0) return SCORE_BUCKET_COLORS[0]; + if (score <= 25) return SCORE_BUCKET_COLORS[1]; + if (score <= 50) return SCORE_BUCKET_COLORS[2]; + if (score <= 75) return SCORE_BUCKET_COLORS[3]; + return SCORE_BUCKET_COLORS[4]; +} + +/** Pick cell colors for a heatmap technique: real status when the layer provides one, else a score-bucket fallback. */ +export function cellColorsFor(technique: { status?: string; score: number }): CellColors { + const status = technique.status as TechniqueStatus | undefined; + if (status && status in STATUS_CELL_COLORS) { + return STATUS_CELL_COLORS[status]; + } + return scoreToBucketColors(technique.score); +} diff --git a/frontend/src/pages/MatrixPage.tsx b/frontend/src/pages/MatrixPage.tsx index aaf1178..c065de7 100644 --- a/frontend/src/pages/MatrixPage.tsx +++ b/frontend/src/pages/MatrixPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; -import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut } from "lucide-react"; +import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut, Grid3X3, List } from "lucide-react"; import { getHeatmapCoverage, getHeatmapThreatActor, @@ -12,11 +12,13 @@ import { type HeatmapFilters as HeatmapFilterParams, } from "../api/heatmap"; import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap"; +import HeatmapTechniqueTable from "../components/heatmap/HeatmapTechniqueTable"; import HeatmapLayerSelector, { type LayerType, } from "../components/heatmap/HeatmapLayerSelector"; import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters"; import HeatmapLegend from "../components/heatmap/HeatmapLegend"; +import type { TechniqueStatus } from "../types/models"; const TACTIC_ORDER = [ "reconnaissance", @@ -49,6 +51,14 @@ export default function MatrixPage() { const [platforms, setPlatforms] = useState([]); const [selectedTactics, setSelectedTactics] = useState([]); const [minScore, setMinScore] = useState(0); + // Only meaningful for layers that carry a real TechniqueStatus + // (coverage, threat-actor) — applied client-side since it's not a score + // threshold (review_required and not_covered share the same score). + const [statusFilter, setStatusFilter] = useState("all"); + + // Matrix (tactic-columned grid) vs List (flat sortable table) — ported + // from the old standalone Techniques page. + const [viewMode, setViewMode] = useState<"matrix" | "list">("matrix"); // Zoom const [zoom, setZoom] = useState("normal"); @@ -104,7 +114,14 @@ export default function MatrixPage() { (activeLayer === "campaign" && !!selectedCampaignId), }); - const techniques = layerData?.techniques || []; + const allTechniques = layerData?.techniques || []; + + const layerHasStatus = activeLayer === "coverage" || activeLayer === "threat-actor"; + + const techniques = useMemo(() => { + if (statusFilter === "all" || !layerHasStatus) return allTechniques; + return allTechniques.filter((t) => t.status === statusFilter); + }, [allTechniques, statusFilter, layerHasStatus]); // Handle cell click - navigate to technique detail const handleCellClick = useCallback( @@ -182,8 +199,34 @@ export default function MatrixPage() { onCampaignChange={setSelectedCampaignId} /> - {/* Right side: Export + Zoom */} + {/* Right side: View toggle + Export + Zoom */}
+ {/* Matrix / List view toggle */} +
+ + +
+ {/* Export dropdown */}
- - {zoom} - - -
+ {/* Zoom controls — matrix view only */} + {viewMode === "matrix" && ( +
+ + + {zoom} + + +
+ )}
{/* Filters */} +
+ {layerHasStatus && ( + + )} +

Failed to load heatmap data

+ ) : viewMode === "list" ? ( + ) : (

Failed to load technique

); @@ -237,11 +237,11 @@ export default function TechniqueDetailPage() {
{/* Back button */} {/* Header */} diff --git a/frontend/src/pages/TechniquesPage.tsx b/frontend/src/pages/TechniquesPage.tsx deleted file mode 100644 index 45557f3..0000000 --- a/frontend/src/pages/TechniquesPage.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Loader2, AlertCircle, Filter, X, Grid3X3, List, AlertTriangle } from "lucide-react"; -import { getTechniques, type TechniqueSummary } from "../api/techniques"; -import AttackMatrix from "../components/AttackMatrix"; -import type { TechniqueStatus } from "../types/models"; -import { useNavigate, Link } from "react-router-dom"; - -const STATUS_OPTIONS: { value: TechniqueStatus | "all"; label: string; color: string }[] = [ - { value: "all", label: "All Statuses", color: "text-gray-400" }, - { value: "validated", label: "Validated", color: "text-green-400" }, - { value: "partial", label: "Partial", color: "text-yellow-400" }, - { value: "in_progress", label: "In Progress", color: "text-blue-400" }, - { value: "not_covered", label: "Not Covered", color: "text-red-400" }, - { value: "not_evaluated", label: "Not Evaluated", color: "text-gray-400" }, -]; - -const PLATFORM_OPTIONS = ["all", "windows", "linux", "macos", "cloud", "network"] as const; - -import StatusBadge from "../components/StatusBadge"; - -const statusBadgeColors: Record = { - validated: "bg-green-900/50 text-green-400 border-green-500/30", - partial: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30", - in_progress: "bg-blue-900/50 text-blue-400 border-blue-500/30", - not_covered: "bg-red-900/50 text-red-400 border-red-500/30", - not_evaluated: "bg-gray-800/50 text-gray-400 border-gray-600/30", - review_required: "bg-orange-900/50 text-orange-400 border-orange-500/30", -}; - -export default function TechniquesPage() { - const navigate = useNavigate(); - const [viewMode, setViewMode] = useState<"matrix" | "list">("matrix"); - const [statusFilter, setStatusFilter] = useState("all"); - const [platformFilter, setPlatformFilter] = useState("all"); - const [tacticFilter, setTacticFilter] = useState("all"); - - const { - data: techniques, - isLoading, - error, - } = useQuery({ - queryKey: ["techniques"], - queryFn: () => getTechniques(), - }); - - // Extract unique tactics from techniques - const availableTactics = useMemo(() => { - if (!techniques) return []; - const tactics = new Set(); - for (const tech of techniques) { - if (tech.tactic) { - tech.tactic.split(",").forEach((t) => tactics.add(t.trim().toLowerCase())); - } - } - return Array.from(tactics).sort(); - }, [techniques]); - - // Apply filters - const filteredTechniques = useMemo(() => { - if (!techniques) return []; - - return techniques.filter((tech: TechniqueSummary) => { - // Status filter - if (statusFilter !== "all" && tech.status_global !== statusFilter) { - return false; - } - - // Tactic filter - if (tacticFilter !== "all") { - const techTactics = tech.tactic?.split(",").map((t) => t.trim().toLowerCase()) || []; - if (!techTactics.includes(tacticFilter)) { - return false; - } - } - - return true; - }); - }, [techniques, statusFilter, tacticFilter]); - - const hasActiveFilters = statusFilter !== "all" || tacticFilter !== "all" || platformFilter !== "all"; - - const clearFilters = () => { - setStatusFilter("all"); - setPlatformFilter("all"); - setTacticFilter("all"); - }; - - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- -

Failed to load techniques

-
- ); - } - - return ( -
- {/* Header */} -
-
-

ATT&CK Techniques

-

- MITRE ATT&CK coverage matrix — click any technique for details -

-
-
- - -
-
- - {/* Legend */} -
-

Coverage legend

-
- - {/* Validated */} -
-
-
-

Validated

-

Detected in ≥2 approved tests

-
-
- - {/* Partial */} -
-
-
-

Partial

-

Some tests validated, coverage incomplete

-
-
- - {/* In Progress */} -
-
-
-

In Progress

-

Tests active, awaiting validation

-
-
- - {/* Not Covered */} -
-
-
-

Not Covered

-

Tested but not detected — confirmed gap

-
-
- - {/* Not Evaluated */} -
-
-
-

Not Evaluated

-

No tests created yet — unknown risk

-
-
- - {/* Review Required */} -
-
-
- -
-
-
-

Review Required

-

Pending lead validation or re-evaluation

-
-
- -
-
- - {/* Filters */} -
-
- - Filters: -
- - {/* Status filter */} - - - {/* Tactic filter */} - - - {/* Platform filter */} - - - {hasActiveFilters && ( - - )} - -
- Showing {filteredTechniques.length} of {techniques?.length || 0} techniques -
-
- - {/* Matrix or List View */} - {viewMode === "matrix" ? ( - - ) : ( -
- - - - - - - - - - - {filteredTechniques.map((tech) => ( - navigate(`/techniques/${tech.mitre_id}`)} - className="cursor-pointer border-b border-gray-800/50 hover:bg-gray-800/50 transition-colors" - > - - - - - - ))} - -
MITRE IDNameTacticStatus
- e.stopPropagation()} - className="font-mono text-cyan-400 hover:underline" - > - {tech.mitre_id} - - {tech.name} - - {tech.tactic?.replace(/-/g, " ") || "—"} - - - -
- {filteredTechniques.length === 0 && ( -
- No techniques found matching your filters -
- )} -
- )} - -
- ); -}