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, " ")} + + +
+
+ ); + })} +
+ + )}