Compare commits

...

2 Commits

Author SHA1 Message Date
kitos 8031682e9c 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.
2026-07-09 14:19:38 +02:00
kitos 0de9f4a6c0 feat(heatmap): expose technique name + real status on every layer
The Coverage Matrix heatmap only ever showed the bare MITRE ID on each
cell (name only via hover tooltip), and colored cells from a continuous
score -> hex gradient with no discrete status concept — unlike the
Techniques page, which showed ID+name inline with a fixed 5-color status
palette. Unifying the two pages' visual style requires both pieces of
data to exist server-side.

- Adds "name" to all 4 layer builders (coverage/threat-actor/detection-
  rules/campaign) — harmless extra field for Navigator exports, lets the
  frontend show it inline.
- Adds "status" (the real TechniqueStatus value) to the coverage and
  threat-actor layers specifically — the only two backed by an actual
  TechniqueStatus concept; detection-rules/campaign scores don't
  correspond to one and don't get this field.
2026-07-09 14:12:58 +02:00
15 changed files with 414 additions and 653 deletions
+18
View File
@@ -353,6 +353,17 @@ def build_coverage_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": tech.mitre_id,
# Technique display name — not part of the official ATT&CK
# Navigator schema, but harmless extra data for Navigator
# imports and lets Aegis's own heatmap UI show names inline
# instead of only ID + hover tooltip.
"name": tech.name,
# Only the coverage layer has a real TechniqueStatus concept —
# exposed so the frontend can color cells by the exact same
# discrete status palette the Techniques page uses, instead of
# a continuous score-derived hex. The other 3 layer types don't
# set this (their score doesn't correspond to a TechniqueStatus).
"status": tech.status_global.value,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
@@ -491,6 +502,11 @@ def build_threat_actor_layer(
)
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"name": tech.name,
# Every appended row here is an actor-used technique (non-actor
# ones are skipped above), so status_global is meaningful here
# too — see the coverage layer's "status" field for why.
"status": tech.status_global.value,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
@@ -586,6 +602,7 @@ def build_detection_rules_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": tech.mitre_id,
"name": tech.name,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
@@ -754,6 +771,7 @@ def build_campaign_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": mitre_id,
"name": tech.name,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
@@ -0,0 +1,85 @@
"""Every heatmap layer must include the technique's display name.
The frontend heatmap previously showed only the MITRE ID on each cell
(name only available via hover tooltip), unlike the Techniques page which
always showed both. Unifying the two pages' cell style requires the name
to be available on every layer's technique entries, not just derived
client-side (HeatmapTechnique has no name field to derive it from).
"""
from app.models.campaign import Campaign, CampaignTest
from app.models.detection_rule import DetectionRule
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.services.heatmap_service import (
build_campaign_layer,
build_coverage_layer,
build_detection_rules_layer,
build_threat_actor_layer,
)
def _seed_technique(db, mitre_id="T1059", name="Command and Scripting Interpreter"):
tech = Technique(mitre_id=mitre_id, name=name, tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
return tech
def test_coverage_layer_includes_technique_name_and_status(db):
tech = _seed_technique(db)
db.commit()
layer = build_coverage_layer(db)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
assert entry["status"] == "not_evaluated"
def test_threat_actor_layer_includes_technique_name_and_status(db):
tech = _seed_technique(db)
actor = ThreatActor(name="APT-Test")
db.add(actor)
db.flush()
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
db.commit()
layer = build_threat_actor_layer(db, actor.id)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
assert entry["status"] == "not_evaluated"
def test_detection_rules_layer_includes_technique_name(db):
tech = _seed_technique(db)
db.add(DetectionRule(
mitre_technique_id=tech.mitre_id, title="Rule 1", source="sigma",
rule_content="title: Rule 1", rule_format="sigma",
))
db.commit()
layer = build_detection_rules_layer(db)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
def test_campaign_layer_includes_technique_name(db, red_lead_user):
tech = _seed_technique(db)
campaign = Campaign(name="Test Campaign", type="custom", status="active", created_by=red_lead_user.id)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="A test", state=TestState.validated, created_by=red_lead_user.id)
db.add(test)
db.flush()
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
db.commit()
layer = build_campaign_layer(db, campaign.id)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
+2 -2
View File
@@ -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 ─────────────────────────────────────────────── */}
<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="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
+4
View File
@@ -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;
-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,
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"] },
],
-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 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,
+23 -45
View File
@@ -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 (
<div
className="relative"
className="relative h-full"
onMouseEnter={() => 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,
}}
>
<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}
</span>
{size !== "compact" && !isDisabled && (
<span className="ml-auto flex items-center gap-0.5 flex-shrink-0">
{testsCount === 0 && <span className="text-[8px]" title="No tests">&#x1F534;</span>}
{reviewRequired && <span className="text-[8px]" title="Review required">&#x26A0;&#xFE0F;</span>}
{isValidated && <span className="text-[8px]" title="Validated">&#x2705;</span>}
</span>
</p>
{size !== "compact" && (
<p className="mt-0.5 truncate text-xs text-gray-300" title={technique.name}>
{technique.name}
</p>
)}
</button>
@@ -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<string, { label: string; items: LegendItem[] }> = {
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 (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<span className="text-sm font-medium text-gray-400">{legend.label}:</span>
{/* Gradient bar */}
<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
key={item.label}
className="flex items-center gap-1.5"
title={item.status ? tooltipText(item.status) : undefined}
>
<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">{legend.label}</p>
<div className="flex flex-wrap gap-x-6 gap-y-3">
{legend.items.map((item) => (
<div
className="h-3 w-3 rounded border border-gray-700"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-gray-400">{item.label}</span>
</div>
))}
key={item.label}
className="flex items-start gap-2.5"
title={item.status ? tooltipText(item.status) : undefined}
>
<div className={`mt-0.5 h-8 w-8 shrink-0 rounded-md border ${item.colors.border} ${item.colors.bg}`} />
<div>
<p className={`text-xs font-semibold ${item.colors.text}`}>{item.label}</p>
<p className="text-xs text-gray-500">{item.description}</p>
</div>
</div>
))}
{/* Review Required — an overlay indicator (amber ring + ⚠️), not a
distinct score tier, so it's shown separately from the gradient. */}
{layerType === "coverage" && (
<div className="flex items-center gap-1.5" title={tooltipText("review_required")}>
<div className="h-3 w-3 rounded border border-gray-700 ring-1 ring-amber-400/60" />
<span className="text-xs text-gray-400"> Review Required (any status)</span>
</div>
)}
{/* 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") && (
<div className="flex items-start gap-2.5" title={tooltipText("review_required")}>
<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>
);
}
@@ -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);
}
+87 -23
View File
@@ -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<string[]>([]);
const [selectedTactics, setSelectedTactics] = useState<string[]>([]);
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
const [zoom, setZoom] = useState<ZoomLevel>("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 */}
<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 */}
<div className="relative">
<button
@@ -211,30 +254,49 @@ export default function MatrixPage() {
)}
</div>
{/* Zoom controls */}
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
<button
onClick={zoomOut}
disabled={zoom === "compact"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="border-x border-gray-700 px-2 py-1 text-[10px] font-medium uppercase text-gray-400">
{zoom}
</span>
<button
onClick={zoomIn}
disabled={zoom === "expanded"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
{/* Zoom controls — matrix view only */}
{viewMode === "matrix" && (
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
<button
onClick={zoomOut}
disabled={zoom === "compact"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="border-x border-gray-700 px-2 py-1 text-[10px] font-medium uppercase text-gray-400">
{zoom}
</span>
<button
onClick={zoomIn}
disabled={zoom === "expanded"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
{/* 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
platforms={platforms}
onPlatformsChange={setPlatforms}
@@ -272,6 +334,8 @@ export default function MatrixPage() {
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load heatmap data</p>
</div>
) : viewMode === "list" ? (
<HeatmapTechniqueTable techniques={techniques} />
) : (
<AdvancedHeatmap
techniques={techniques}
+4 -4
View File
@@ -212,11 +212,11 @@ export default function TechniqueDetailPage() {
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load technique</p>
<button
onClick={() => navigate("/techniques")}
onClick={() => navigate("/matrix")}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to techniques
Back to matrix
</button>
</div>
);
@@ -237,11 +237,11 @@ export default function TechniqueDetailPage() {
<div className="space-y-6">
{/* Back 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"
>
<ArrowLeft className="h-4 w-4" />
Back to techniques
Back to matrix
</button>
{/* 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>
);
}