From 8b035b5c5c4148dc8a3b3a0c6e805edf0eab3a6c Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 16:45:47 +0200 Subject: [PATCH] fix(compliance): fix broken table layout and expand caused by nested tbody elements Rewrote ControlsTable with React fragments instead of nested tags, added ScoreBar component, improved status badges, filter header strip, and grid layout for expanded technique cards. Co-Authored-By: Claude Sonnet 4.6 --- .../components/compliance/ControlsTable.tsx | 253 ++++++++++-------- 1 file changed, 140 insertions(+), 113 deletions(-) diff --git a/frontend/src/components/compliance/ControlsTable.tsx b/frontend/src/components/compliance/ControlsTable.tsx index 616c467..ba133b6 100644 --- a/frontend/src/components/compliance/ControlsTable.tsx +++ b/frontend/src/components/compliance/ControlsTable.tsx @@ -1,26 +1,42 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ChevronDown, ChevronRight, Search, Filter } from "lucide-react"; +import { ChevronDown, ChevronRight, Search, Filter, ExternalLink } from "lucide-react"; import type { ComplianceControlStatus } from "../../api/compliance"; interface ControlsTableProps { controls: ComplianceControlStatus[]; } -const STATUS_COLORS: Record = { - covered: { bg: "bg-green-500/10", text: "text-green-400", dot: "bg-green-500" }, - partially_covered: { bg: "bg-yellow-500/10", text: "text-yellow-400", dot: "bg-yellow-500" }, - not_covered: { bg: "bg-red-500/10", text: "text-red-400", dot: "bg-red-500" }, - not_evaluated: { bg: "bg-gray-500/10", text: "text-gray-400", dot: "bg-gray-500" }, +const STATUS_COLORS: Record = { + covered: { bg: "bg-green-500/10", text: "text-green-400", dot: "bg-green-500", border: "border-green-500/20" }, + partially_covered:{ bg: "bg-yellow-500/10", text: "text-yellow-400", dot: "bg-yellow-500", border: "border-yellow-500/20" }, + not_covered: { bg: "bg-red-500/10", text: "text-red-400", dot: "bg-red-500", border: "border-red-500/20" }, + not_evaluated: { bg: "bg-gray-500/10", text: "text-gray-400", dot: "bg-gray-500", border: "border-gray-600/20" }, }; const STATUS_LABELS: Record = { - covered: "Covered", + covered: "Covered", partially_covered: "Partial", - not_covered: "Not Covered", - not_evaluated: "Not Evaluated", + not_covered: "Not Covered", + not_evaluated: "Not Evaluated", }; +function ScoreBar({ score }: { score: number }) { + const color = + score >= 70 ? "bg-green-500" + : score >= 30 ? "bg-yellow-500" + : score > 0 ? "bg-red-500" + : "bg-gray-700"; + return ( +
+
+
+
+ {score.toFixed(0)} +
+ ); +} + export default function ControlsTable({ controls }: ControlsTableProps) { const navigate = useNavigate(); const [expandedId, setExpandedId] = useState(null); @@ -28,10 +44,8 @@ export default function ControlsTable({ controls }: ControlsTableProps) { const [categoryFilter, setCategoryFilter] = useState("all"); const [search, setSearch] = useState(""); - // Extract unique categories const categories = [...new Set(controls.map((c) => c.category).filter(Boolean))] as string[]; - // Apply filters const filteredControls = controls.filter((c) => { if (statusFilter !== "all" && c.status !== statusFilter) return false; if (categoryFilter !== "all" && c.category !== categoryFilter) return false; @@ -39,23 +53,20 @@ export default function ControlsTable({ controls }: ControlsTableProps) { const q = search.toLowerCase(); return ( c.control_id.toLowerCase().includes(q) || - c.title.toLowerCase().includes(q) + c.title.toLowerCase().includes(q) || + (c.category || "").toLowerCase().includes(q) ); } return true; }); - const toggleExpand = (controlId: string) => { - setExpandedId(expandedId === controlId ? null : controlId); - }; + const toggleExpand = (id: string) => setExpandedId(expandedId === id ? null : id); return ( -
- {/* Filters row */} -
-
- -
+
+ {/* Filters */} +
+ - + {categories.length > 0 && ( + + )} -
+
setSearch(e.target.value)} - placeholder="Search by ID or title..." + placeholder="Search ID or title…" className="w-full rounded-lg border border-gray-700 bg-gray-800 py-1.5 pl-8 pr-3 text-xs text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" />
- {filteredControls.length} of {controls.length} controls + {filteredControls.length} / {controls.length}
{/* Table */} -
- +
+
- - - - - - - + + + + + + + + + {filteredControls.length === 0 && ( + + + + )} + {filteredControls.map((control) => { const isExpanded = expandedId === control.control_id; const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated; return ( - + <> + {/* Main row */} toggleExpand(control.control_id)} > - - - - - - - - {/* Expanded row: technique details */} - {isExpanded && control.techniques.length > 0 && ( - - + )} - + ); })}
- ControlTitleCategoryStatusScoreTechniques
+ Control IDTitleCategoryStatusScoreTechniques
+ No controls match the current filters. +
- {isExpanded ? ( - - ) : ( - - )} + + {isExpanded + ? + : + } + {control.control_id} - {control.title} + + {control.title} - {control.category} + + {control.category || "—"} + - {STATUS_LABELS[control.status]} + {STATUS_LABELS[control.status] ?? control.status} - {control.score.toFixed(1)} + + - {control.techniques_covered}/{control.techniques_count} + + 0 ? "text-gray-200" : "text-gray-600"}> + {control.techniques_covered} + + / {control.techniques_count}
-
-

- Mapped Techniques -

- {control.techniques.map((tech) => { - const techStatusColor = - tech.score >= 70 - ? "text-green-400" - : tech.score >= 30 - ? "text-yellow-400" - : tech.score > 0 - ? "text-red-400" - : "text-gray-500"; + {/* Expanded detail row */} + {isExpanded && ( +
+ {control.techniques.length === 0 ? ( +

No techniques mapped to this control.

+ ) : ( +
+

+ Mapped Techniques ({control.techniques.length}) +

+
+ {control.techniques.map((tech) => { + const techStyle = + tech.score >= 70 ? "border-green-500/20 bg-green-500/5 text-green-400" + : tech.score >= 30 ? "border-yellow-500/20 bg-yellow-500/5 text-yellow-400" + : tech.score > 0 ? "border-red-500/20 bg-red-500/5 text-red-400" + : "border-gray-700 bg-gray-800/30 text-gray-500"; - return ( -
{ - e.stopPropagation(); - navigate(`/techniques/${tech.mitre_id}`); - }} - > -
- - {tech.mitre_id} - - - {tech.name} - -
-
- - {tech.status.replace(/_/g, " ")} - - - {tech.score.toFixed(1)} - -
-
- ); - })} -
+ return ( +
{ + e.stopPropagation(); + navigate(`/techniques/${tech.mitre_id}`); + }} + > +
+ + {tech.mitre_id} + + + {tech.name} + +
+
+ + {tech.status.replace(/_/g, " ")} + + +
+
+ ); + })} +
+ + )}