feat(matrix): unify Techniques and Coverage Matrix into one page
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

Techniques (/techniques) and Coverage Matrix (/matrix) showed the same
underlying data (Technique.status_global) with completely different
visual styles — discrete Tailwind status colors + ID+name cells on
Techniques, vs. a continuous score-gradient hex color + ID-only cells
(name via tooltip) on the heatmap. Coverage Matrix also has 3 layers
(Threat Actor, Detection Rules, Campaign) with no Techniques equivalent,
so Techniques was the strict subset — merged into Coverage Matrix rather
than the other way around.

- HeatmapCell now uses the same discrete status-color palette as the old
  TechniqueCell (cellColors.ts), shows the technique name inline, and
  keeps zoom/virtualization/hover-tooltip untouched.
- HeatmapLegend restyled to match (swatch + label + description per
  tier, review_required as an overlay badge) instead of the old
  hex-gradient bar.
- MatrixPage gains a Matrix/List view toggle and a status filter
  (coverage/threat-actor layers only) — both ported from the old
  Techniques page — so nothing it offered is lost.
- /techniques now redirects to /matrix; removed TechniquesPage,
  AttackMatrix, TechniqueCell (fully superseded). /techniques/:mitreId
  (detail) and /techniques/review-queue are untouched.

Known pre-existing limitation carried over, not introduced or fixed
here: the heatmap only places a multi-tactic technique under its first
tactic column (_format_tactic takes tactic_str.split(",")[0]), while the
old Techniques matrix placed it under every tactic it belongs to.
This commit is contained in:
kitos
2026-07-09 14:19:38 +02:00
parent 0de9f4a6c0
commit 8031682e9c
13 changed files with 311 additions and 653 deletions
+2 -2
View File
@@ -9,7 +9,6 @@ import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */ /* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
const TechniquesPage = React.lazy(() => import("./pages/TechniquesPage"));
const MatrixPage = React.lazy(() => import("./pages/MatrixPage")); const MatrixPage = React.lazy(() => import("./pages/MatrixPage"));
const ExecutiveDashboardPage = React.lazy(() => import("./pages/ExecutiveDashboardPage")); const ExecutiveDashboardPage = React.lazy(() => import("./pages/ExecutiveDashboardPage"));
const CompliancePage = React.lazy(() => import("./pages/CompliancePage")); const CompliancePage = React.lazy(() => import("./pages/CompliancePage"));
@@ -50,7 +49,8 @@ export default function App() {
{/* ── Core ─────────────────────────────────────────────── */} {/* ── Core ─────────────────────────────────────────────── */}
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/techniques" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniquesPage /></Suspense>} /> {/* Techniques was merged into the Coverage Matrix's "Coverage" layer + List view */}
<Route path="/techniques" element={<Navigate to="/matrix" replace />} />
<Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} /> <Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} />
<Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} /> <Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
+4
View File
@@ -9,6 +9,10 @@ export interface HeatmapMetadata {
export interface HeatmapTechnique { export interface HeatmapTechnique {
techniqueID: string; 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; tactic: string;
color: string; color: string;
score: number; score: number;
-112
View File
@@ -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<string, TechniqueSummary[]> = {};
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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<p className="text-gray-400">No techniques found matching your filters</p>
</div>
);
}
return (
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
<div className="min-w-max p-4">
<div className="flex gap-3">
{orderedTactics.map((tactic) => (
<div key={tactic} className="w-48 flex-shrink-0">
{/* Tactic header */}
<div className="mb-3 rounded-lg bg-gray-800 px-3 py-2">
<h3 className="text-center text-sm font-semibold text-cyan-400">
{formatTacticName(tactic)}
</h3>
<p className="mt-0.5 text-center text-xs text-gray-500">
{groupedByTactic[tactic]?.length || 0} techniques
</p>
</div>
{/* Technique cells */}
<div className="space-y-2">
{groupedByTactic[tactic]?.map((tech) => (
<TechniqueCell
key={`${tactic}-${tech.mitre_id}`}
mitreId={tech.mitre_id}
name={tech.name}
status={tech.status_global}
reviewRequired={tech.review_required}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
-2
View File
@@ -16,7 +16,6 @@ import {
Crosshair, Crosshair,
Zap, Zap,
Grid3X3, Grid3X3,
List,
Gauge, Gauge,
ShieldCheck, ShieldCheck,
GitCompareArrows, GitCompareArrows,
@@ -44,7 +43,6 @@ const mainLinks: NavItem[] = [
label: "ATT&CK", label: "ATT&CK",
icon: Grid3X3, icon: Grid3X3,
children: [ children: [
{ to: "/techniques", label: "Techniques", icon: List },
{ to: "/matrix", label: "Coverage Matrix", icon: Grid3X3 }, { to: "/matrix", label: "Coverage Matrix", icon: Grid3X3 },
{ to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] }, { to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] },
], ],
-73
View File
@@ -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<TechniqueStatus, { bg: string; border: string; text: string }> = {
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 (
<Link
to={`/techniques/${mitreId}`}
className={`
relative block w-full rounded-md border p-2 text-left transition-all
hover:scale-[1.02] hover:shadow-lg hover:z-10
${colors.bg} ${colors.border}
`}
>
{reviewRequired && (
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
<AlertTriangle className="h-3 w-3 text-white" />
</div>
)}
<p className={`text-xs font-semibold ${colors.text}`}>{mitreId}</p>
<p className="mt-0.5 truncate text-xs text-gray-300" title={name}>
{name}
</p>
</Link>
);
}
@@ -47,7 +47,9 @@ function TacticColumn({
}) { }) {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(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({ const rowVirtualizer = useVirtualizer({
count: techniques.length, count: techniques.length,
+23 -45
View File
@@ -1,5 +1,7 @@
import React, { useState, useMemo, useCallback } from "react"; import React, { useState, useMemo, useCallback } from "react";
import { AlertTriangle } from "lucide-react";
import type { HeatmapTechnique } from "../../api/heatmap"; import type { HeatmapTechnique } from "../../api/heatmap";
import { cellColorsFor } from "./cellColors";
import HeatmapTooltip from "./HeatmapTooltip"; import HeatmapTooltip from "./HeatmapTooltip";
interface HeatmapCellProps { interface HeatmapCellProps {
@@ -12,45 +14,24 @@ interface HeatmapCellProps {
* Memoized heatmap cell — this component renders 3000+ times in the * Memoized heatmap cell — this component renders 3000+ times in the
* full ATT&CK matrix, so React.memo prevents unnecessary re-renders * full ATT&CK matrix, so React.memo prevents unnecessary re-renders
* when only sibling cells change. * 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 HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
const [showTooltip, setShowTooltip] = useState(false); 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; const isDisabled = !technique.enabled;
const colors = useMemo(() => cellColorsFor(technique), [technique]);
// Memoize text color (derived from background hex) const reviewRequired = technique.status === "review_required";
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 handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]); const handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]);
return ( return (
<div <div
className="relative" className="relative h-full"
onMouseEnter={() => setShowTooltip(true)} onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)} onMouseLeave={() => setShowTooltip(false)}
> >
@@ -58,28 +39,25 @@ const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }
onClick={handleClick} onClick={handleClick}
disabled={isDisabled} disabled={isDisabled}
className={` className={`
w-full rounded border transition-all duration-150 relative block h-full w-full rounded-md border p-1.5 text-left transition-all overflow-hidden
${sizeClasses[size]}
${isDisabled ${isDisabled
? "cursor-default border-gray-800/30 bg-gray-900/20 opacity-30" ? "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,
}}
> >
<span className={`truncate font-mono font-medium leading-tight ${textColor}`}> {reviewRequired && !isDisabled && (
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
<AlertTriangle className="h-3 w-3 text-white" />
</div>
)}
<p className={`truncate text-xs font-semibold ${isDisabled ? "text-gray-600" : colors.text}`}>
{technique.techniqueID} {technique.techniqueID}
</span> </p>
{size !== "compact" && !isDisabled && ( {size !== "compact" && (
<span className="ml-auto flex items-center gap-0.5 flex-shrink-0"> <p className="mt-0.5 truncate text-xs text-gray-300" title={technique.name}>
{testsCount === 0 && <span className="text-[8px]" title="No tests">&#x1F534;</span>} {technique.name}
{reviewRequired && <span className="text-[8px]" title="Review required">&#x26A0;&#xFE0F;</span>} </p>
{isValidated && <span className="text-[8px]" title="Validated">&#x2705;</span>}
</span>
)} )}
</button> </button>
@@ -1,5 +1,7 @@
import { AlertTriangle } from "lucide-react";
import type { TechniqueStatus } from "../../types/models"; import type { TechniqueStatus } from "../../types/models";
import { TOOLTIPS } from "../StatusBadge"; import { TOOLTIPS } from "../StatusBadge";
import { STATUS_CELL_COLORS, type CellColors } from "./cellColors";
interface HeatmapLegendProps { interface HeatmapLegendProps {
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign"; 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(" ")}`; return `${t.heading}${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`;
} }
const LEGENDS: Record< interface LegendItem {
string, colors: CellColors;
{ label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] } 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<string, { label: string; items: LegendItem[] }> = {
coverage: { coverage: {
label: "Coverage Status", label: "Coverage legend",
colors: [ items: [
{ color: "#d3d3d3", label: "Not Evaluated (0)", status: "not_evaluated" }, { colors: STATUS_CELL_COLORS.validated, label: "Validated", description: "Detected in ≥1 approved test", status: "validated" },
{ color: "#ff6666", label: "Not Covered (10)", status: "not_covered" }, { colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" },
{ color: "#ff9933", label: "In Progress (30)", status: "in_progress" }, { colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" },
{ color: "#ffff66", label: "Partial (60)", status: "partial" }, { colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Tested but not detected — confirmed gap", status: "not_covered" },
{ color: "#66ff66", label: "Validated (100)", status: "validated" }, { colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "No tests created yet — unknown risk", status: "not_evaluated" },
], ],
}, },
"threat-actor": { "threat-actor": {
label: "Threat Actor Coverage", label: "Threat actor coverage legend",
colors: [ items: [
{ color: "#d3d3d3", label: "Not Used by Actor" }, { colors: STATUS_CELL_COLORS.validated, label: "Covered", description: "Detected in ≥1 approved test", status: "validated" },
{ color: "#ff6666", label: "Not Covered (10)" }, { colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" },
{ color: "#ff9933", label: "In Progress (30)" }, { colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" },
{ color: "#ffff66", label: "Partial (60)" }, { colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Used by this actor but not detected", status: "not_covered" },
{ color: "#66ff66", label: "Covered (100)" }, { colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "Used by this actor, no tests yet", status: "not_evaluated" },
], ],
}, },
"detection-rules": { "detection-rules": {
label: "Detection Rules Coverage", label: "Detection rules legend",
colors: [ items: [
{ color: "#d3d3d3", label: "0 rules" }, { colors: BUCKET_COLORS[0], label: "0 rules", description: "No detection rules cataloged" },
{ color: "#ff6666", label: "1 rule" }, { colors: BUCKET_COLORS[1], label: "1 rule", description: "Minimal detection coverage" },
{ color: "#ff9933", label: "2 rules" }, { colors: BUCKET_COLORS[2], label: "2 rules", description: "Partial detection coverage" },
{ color: "#ffff66", label: "3 rules" }, { colors: BUCKET_COLORS[3], label: "3 rules", description: "Good detection coverage" },
{ color: "#66ff66", label: "4+ rules" }, { colors: BUCKET_COLORS[4], label: "4+ rules", description: "Strong detection coverage" },
], ],
}, },
campaign: { campaign: {
label: "Campaign Progress", label: "Campaign progress legend",
colors: [ items: [
{ color: "#ff6666", label: "Draft / Rejected" }, { colors: BUCKET_COLORS[1], label: "Draft / Rejected", description: "Not yet started or sent back" },
{ color: "#ff9933", label: "Red Executing (30)" }, { colors: BUCKET_COLORS[2], label: "Red Executing", description: "Red Team actively working" },
{ color: "#ffff66", label: "Blue Evaluating (50)" }, { colors: BUCKET_COLORS[3], label: "Blue Evaluating", description: "Blue Team actively working" },
{ color: "#66ff66", label: "Validated (100)" }, { 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; const legend = LEGENDS[layerType] || LEGENDS.coverage;
return ( return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<span className="text-sm font-medium text-gray-400">{legend.label}:</span> <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">{legend.label}</p>
<div className="flex flex-wrap gap-x-6 gap-y-3">
{/* Gradient bar */} {legend.items.map((item) => (
<div className="flex items-center gap-1">
<div
className="h-3 w-40 rounded"
style={{
background: `linear-gradient(to right, ${legend.colors.map((c) => c.color).join(", ")})`,
}}
/>
</div>
{/* Individual labels */}
{legend.colors.map((item) => (
<div <div
key={item.label} key={item.label}
className="flex items-center gap-1.5" className="flex items-start gap-2.5"
title={item.status ? tooltipText(item.status) : undefined} title={item.status ? tooltipText(item.status) : undefined}
> >
<div <div className={`mt-0.5 h-8 w-8 shrink-0 rounded-md border ${item.colors.border} ${item.colors.bg}`} />
className="h-3 w-3 rounded border border-gray-700" <div>
style={{ backgroundColor: item.color }} <p className={`text-xs font-semibold ${item.colors.text}`}>{item.label}</p>
/> <p className="text-xs text-gray-500">{item.description}</p>
<span className="text-xs text-gray-400">{item.label}</span> </div>
</div> </div>
))} ))}
{/* Review Required — an overlay indicator (amber ring + ⚠️), not a {/* Review Required — an overlay indicator (⚠️ badge), not a
distinct score tier, so it's shown separately from the gradient. */} distinct tier, so it's shown separately for layers that carry
{layerType === "coverage" && ( a real technique status. */}
<div className="flex items-center gap-1.5" title={tooltipText("review_required")}> {(layerType === "coverage" || layerType === "threat-actor") && (
<div className="h-3 w-3 rounded border border-gray-700 ring-1 ring-amber-400/60" /> <div className="flex items-start gap-2.5" title={tooltipText("review_required")}>
<span className="text-xs text-gray-400"> Review Required (any status)</span> <div className="relative mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40">
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
<AlertTriangle className="h-2.5 w-2.5 text-white" />
</div>
</div>
<div>
<p className="text-xs font-semibold text-yellow-400">Review Required</p>
<p className="text-xs text-gray-500">Pending lead validation or re-evaluation</p>
</div>
</div> </div>
)} )}
</div> </div>
</div>
); );
} }
@@ -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 (
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-400">
<th className="px-4 py-3 font-medium">MITRE ID</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Tactic</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{techniques.map((tech) => (
<tr
key={tech.techniqueID}
onClick={() => navigate(`/techniques/${tech.techniqueID}`)}
className="cursor-pointer border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="px-4 py-3 font-mono text-xs text-cyan-400">{tech.techniqueID}</td>
<td className="px-4 py-3 text-gray-200">{tech.name || "—"}</td>
<td className="px-4 py-3 text-xs capitalize text-gray-400">
{(tech.tactic || "—").replace(/-/g, " ")}
</td>
<td className="px-4 py-3">
{tech.status ? (
<StatusBadge status={tech.status as TechniqueStatus} size="sm" />
) : (
<span className="text-xs text-gray-400">{tech.comment}</span>
)}
</td>
</tr>
))}
{techniques.length === 0 && (
<tr>
<td colSpan={4} className="py-16 text-center text-sm text-gray-500">
No techniques match the current filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
@@ -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<TechniqueStatus, CellColors> = {
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);
}
+68 -4
View File
@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; 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 { import {
getHeatmapCoverage, getHeatmapCoverage,
getHeatmapThreatActor, getHeatmapThreatActor,
@@ -12,11 +12,13 @@ import {
type HeatmapFilters as HeatmapFilterParams, type HeatmapFilters as HeatmapFilterParams,
} from "../api/heatmap"; } from "../api/heatmap";
import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap"; import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap";
import HeatmapTechniqueTable from "../components/heatmap/HeatmapTechniqueTable";
import HeatmapLayerSelector, { import HeatmapLayerSelector, {
type LayerType, type LayerType,
} from "../components/heatmap/HeatmapLayerSelector"; } from "../components/heatmap/HeatmapLayerSelector";
import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters"; import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters";
import HeatmapLegend from "../components/heatmap/HeatmapLegend"; import HeatmapLegend from "../components/heatmap/HeatmapLegend";
import type { TechniqueStatus } from "../types/models";
const TACTIC_ORDER = [ const TACTIC_ORDER = [
"reconnaissance", "reconnaissance",
@@ -49,6 +51,14 @@ export default function MatrixPage() {
const [platforms, setPlatforms] = useState<string[]>([]); const [platforms, setPlatforms] = useState<string[]>([]);
const [selectedTactics, setSelectedTactics] = useState<string[]>([]); const [selectedTactics, setSelectedTactics] = useState<string[]>([]);
const [minScore, setMinScore] = useState(0); 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<TechniqueStatus | "all">("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 // Zoom
const [zoom, setZoom] = useState<ZoomLevel>("normal"); const [zoom, setZoom] = useState<ZoomLevel>("normal");
@@ -104,7 +114,14 @@ export default function MatrixPage() {
(activeLayer === "campaign" && !!selectedCampaignId), (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 // Handle cell click - navigate to technique detail
const handleCellClick = useCallback( const handleCellClick = useCallback(
@@ -182,8 +199,34 @@ export default function MatrixPage() {
onCampaignChange={setSelectedCampaignId} onCampaignChange={setSelectedCampaignId}
/> />
{/* Right side: Export + Zoom */} {/* Right side: View toggle + Export + Zoom */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Matrix / List view toggle */}
<div className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 p-1">
<button
onClick={() => setViewMode("matrix")}
className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-xs transition-colors ${
viewMode === "matrix"
? "bg-cyan-500/20 text-cyan-400"
: "text-gray-400 hover:text-gray-200"
}`}
>
<Grid3X3 className="h-3.5 w-3.5" />
Matrix
</button>
<button
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-xs transition-colors ${
viewMode === "list"
? "bg-cyan-500/20 text-cyan-400"
: "text-gray-400 hover:text-gray-200"
}`}
>
<List className="h-3.5 w-3.5" />
List
</button>
</div>
{/* Export dropdown */} {/* Export dropdown */}
<div className="relative"> <div className="relative">
<button <button
@@ -211,7 +254,8 @@ export default function MatrixPage() {
)} )}
</div> </div>
{/* Zoom controls */} {/* Zoom controls — matrix view only */}
{viewMode === "matrix" && (
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800"> <div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
<button <button
onClick={zoomOut} onClick={zoomOut}
@@ -231,10 +275,28 @@ export default function MatrixPage() {
<ZoomIn className="h-3.5 w-3.5" /> <ZoomIn className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{layerHasStatus && (
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as TechniqueStatus | "all")}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="validated">Validated</option>
<option value="partial">Partial</option>
<option value="in_progress">In Progress</option>
<option value="not_covered">Not Covered</option>
<option value="not_evaluated">Not Evaluated</option>
<option value="review_required">Review Required</option>
</select>
)}
</div>
<HeatmapFiltersComponent <HeatmapFiltersComponent
platforms={platforms} platforms={platforms}
onPlatformsChange={setPlatforms} onPlatformsChange={setPlatforms}
@@ -272,6 +334,8 @@ export default function MatrixPage() {
<AlertCircle className="h-10 w-10 text-red-400" /> <AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load heatmap data</p> <p className="text-red-400">Failed to load heatmap data</p>
</div> </div>
) : viewMode === "list" ? (
<HeatmapTechniqueTable techniques={techniques} />
) : ( ) : (
<AdvancedHeatmap <AdvancedHeatmap
techniques={techniques} techniques={techniques}
+4 -4
View File
@@ -212,11 +212,11 @@ export default function TechniqueDetailPage() {
<AlertCircle className="h-10 w-10 text-red-400" /> <AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load technique</p> <p className="text-red-400">Failed to load technique</p>
<button <button
onClick={() => navigate("/techniques")} onClick={() => navigate("/matrix")}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline" className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to techniques Back to matrix
</button> </button>
</div> </div>
); );
@@ -237,11 +237,11 @@ export default function TechniqueDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Back button */} {/* Back button */}
<button <button
onClick={() => navigate("/techniques")} onClick={() => navigate("/matrix")}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors" className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to techniques Back to matrix
</button> </button>
{/* Header */} {/* Header */}
-326
View File
@@ -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<TechniqueStatus, string> = {
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<TechniqueStatus | "all">("all");
const [platformFilter, setPlatformFilter] = useState<string>("all");
const [tacticFilter, setTacticFilter] = useState<string>("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<string>();
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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load techniques</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">ATT&CK Techniques</h1>
<p className="mt-1 text-sm text-gray-400">
MITRE ATT&CK coverage matrix click any technique for details
</p>
</div>
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 p-1">
<button
onClick={() => setViewMode("matrix")}
className={`flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors ${
viewMode === "matrix"
? "bg-cyan-500/20 text-cyan-400"
: "text-gray-400 hover:text-gray-200"
}`}
>
<Grid3X3 className="h-4 w-4" />
Matrix
</button>
<button
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors ${
viewMode === "list"
? "bg-cyan-500/20 text-cyan-400"
: "text-gray-400 hover:text-gray-200"
}`}
>
<List className="h-4 w-4" />
List
</button>
</div>
</div>
{/* Legend */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Coverage legend</p>
<div className="flex flex-wrap gap-x-6 gap-y-3">
{/* Validated */}
<div className="flex items-start gap-2.5">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-green-500/50 bg-green-900/40" />
<div>
<p className="text-xs font-semibold text-green-400">Validated</p>
<p className="text-xs text-gray-500">Detected in 2 approved tests</p>
</div>
</div>
{/* Partial */}
<div className="flex items-start gap-2.5">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40" />
<div>
<p className="text-xs font-semibold text-yellow-400">Partial</p>
<p className="text-xs text-gray-500">Some tests validated, coverage incomplete</p>
</div>
</div>
{/* In Progress */}
<div className="flex items-start gap-2.5">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-blue-500/50 bg-blue-900/40" />
<div>
<p className="text-xs font-semibold text-blue-400">In Progress</p>
<p className="text-xs text-gray-500">Tests active, awaiting validation</p>
</div>
</div>
{/* Not Covered */}
<div className="flex items-start gap-2.5">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-red-500/50 bg-red-900/40" />
<div>
<p className="text-xs font-semibold text-red-400">Not Covered</p>
<p className="text-xs text-gray-500">Tested but not detected confirmed gap</p>
</div>
</div>
{/* Not Evaluated */}
<div className="flex items-start gap-2.5">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-gray-600/50 bg-gray-800/40" />
<div>
<p className="text-xs font-semibold text-gray-400">Not Evaluated</p>
<p className="text-xs text-gray-500">No tests created yet unknown risk</p>
</div>
</div>
{/* Review Required */}
<div className="flex items-start gap-2.5">
<div className="relative mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40">
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
<AlertTriangle className="h-2.5 w-2.5 text-white" />
</div>
</div>
<div>
<p className="text-xs font-semibold text-yellow-400">Review Required</p>
<p className="text-xs text-gray-500">Pending lead validation or re-evaluation</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-400">Filters:</span>
</div>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as TechniqueStatus | "all")}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Tactic filter */}
<select
value={tacticFilter}
onChange={(e) => setTacticFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Tactics</option>
{availableTactics.map((tactic) => (
<option key={tactic} value={tactic}>
{tactic
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</option>
))}
</select>
{/* Platform filter */}
<select
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{PLATFORM_OPTIONS.map((platform) => (
<option key={platform} value={platform}>
{platform === "all" ? "All Platforms" : platform.charAt(0).toUpperCase() + platform.slice(1)}
</option>
))}
</select>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:border-red-500/50 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
Clear
</button>
)}
<div className="ml-auto text-sm text-gray-500">
Showing {filteredTechniques.length} of {techniques?.length || 0} techniques
</div>
</div>
{/* Matrix or List View */}
{viewMode === "matrix" ? (
<AttackMatrix techniques={filteredTechniques} />
) : (
<div className="rounded-xl border border-gray-800 bg-gray-900">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 font-medium text-gray-400">MITRE ID</th>
<th className="px-4 py-3 font-medium text-gray-400">Name</th>
<th className="px-4 py-3 font-medium text-gray-400">Tactic</th>
<th className="px-4 py-3 font-medium text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{filteredTechniques.map((tech) => (
<tr
key={tech.id}
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
className="cursor-pointer border-b border-gray-800/50 hover:bg-gray-800/50 transition-colors"
>
<td className="px-4 py-3">
<Link
to={`/techniques/${tech.mitre_id}`}
onClick={(e) => e.stopPropagation()}
className="font-mono text-cyan-400 hover:underline"
>
{tech.mitre_id}
</Link>
</td>
<td className="px-4 py-3 text-gray-200">{tech.name}</td>
<td className="px-4 py-3">
<span className="text-gray-400 capitalize">
{tech.tactic?.replace(/-/g, " ") || "—"}
</span>
</td>
<td className="px-4 py-3 overflow-visible">
<StatusBadge status={tech.status_global} />
</td>
</tr>
))}
</tbody>
</table>
{filteredTechniques.length === 0 && (
<div className="p-8 text-center text-gray-400">
No techniques found matching your filters
</div>
)}
</div>
)}
</div>
);
}