From 8cf8001de52287ce2a910a8c005cc6e3c505187e Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 8 Jul 2026 08:46:24 +0200 Subject: [PATCH] feat(frontend): My Reviews queue, duplicate-test badge, tactic order, legend + tooltip fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestsPage gains a "My Reviews" toggle for red_lead/blue_lead, separate from "My Tasks" — filters by reviewer_id at the red_review/blue_review gate instead of the unrelated in_review validation stage - Test Catalog cards now warn when a technique already has existing tests, using the backend's new existing_test_count - ExecutiveDashboardPage drops its duplicate client-side tactic-order sort now that the backend returns canonical order; time-range filter now notes which sections it actually scopes - HeatmapLegend adds the missing review_required swatch (as an overlay indicator, matching how it's actually rendered on cells) and hover tooltips reusing StatusBadge's copy instead of being unexplained static color chips - Fix a real React key bug in ControlsTable: rows were grouped in a keyless shorthand fragment, which can misreconcile when the table is filtered/sorted; switched to a keyed Fragment - Minor: CompliancePage summary-card grid no longer strands a lone card on tablet widths --- frontend/src/api/tests.ts | 3 ++ frontend/src/components/StatusBadge.tsx | 2 +- .../components/compliance/ControlsTable.tsx | 7 ++- .../src/components/heatmap/HeatmapLegend.tsx | 35 +++++++++--- frontend/src/pages/CompliancePage.tsx | 2 +- frontend/src/pages/ExecutiveDashboardPage.tsx | 51 ++++++++++-------- frontend/src/pages/TestCatalogPage.tsx | 12 +++++ frontend/src/pages/TestsPage.tsx | 54 ++++++++++++++++--- frontend/src/types/models.ts | 2 + 9 files changed, 125 insertions(+), 43 deletions(-) diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index b1a3f7f..233b123 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -70,6 +70,8 @@ export interface TestListFilters { platform?: string; created_by?: string; pending_validation_side?: "red" | "blue"; + /** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */ + reviewer_id?: string; not_in_any_campaign?: boolean; assigned_to_me?: boolean; unassigned_red?: boolean; @@ -88,6 +90,7 @@ export async function getTests(filters?: TestListFilters): Promise { if (filters?.platform) params.append("platform", filters.platform); if (filters?.created_by) params.append("created_by", filters.created_by); if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side); + if (filters?.reviewer_id) params.append("reviewer_id", filters.reviewer_id); if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true"); if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index c5259d9..355ea5a 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -25,7 +25,7 @@ const BADGE_LABELS: Record = { interface TooltipLine { label: string; text: string } -const TOOLTIPS: Record = { +export const TOOLTIPS: Record = { validated: { heading: "✅ Validated", lines: [ diff --git a/frontend/src/components/compliance/ControlsTable.tsx b/frontend/src/components/compliance/ControlsTable.tsx index 68fb1f9..66e5858 100644 --- a/frontend/src/components/compliance/ControlsTable.tsx +++ b/frontend/src/components/compliance/ControlsTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ChevronDown, ChevronRight, Search, Filter, ExternalLink, Info, ShieldAlert } from "lucide-react"; import type { ComplianceControlStatus } from "../../api/compliance"; @@ -139,10 +139,9 @@ export default function ControlsTable({ controls }: ControlsTableProps) { const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated; return ( - <> + {/* Main row */} )} - + ); })} diff --git a/frontend/src/components/heatmap/HeatmapLegend.tsx b/frontend/src/components/heatmap/HeatmapLegend.tsx index 738ff1b..d28b32b 100644 --- a/frontend/src/components/heatmap/HeatmapLegend.tsx +++ b/frontend/src/components/heatmap/HeatmapLegend.tsx @@ -1,19 +1,27 @@ +import type { TechniqueStatus } from "../../types/models"; +import { TOOLTIPS } from "../StatusBadge"; + interface HeatmapLegendProps { layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign"; } +function tooltipText(status: TechniqueStatus): string { + const t = TOOLTIPS[status]; + return `${t.heading} — ${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`; +} + const LEGENDS: Record< string, - { label: string; colors: { color: string; label: string }[] } + { label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] } > = { coverage: { label: "Coverage Status", colors: [ - { color: "#d3d3d3", label: "Not Evaluated (0)" }, - { color: "#ff6666", label: "Not Covered (10)" }, - { color: "#ff9933", label: "In Progress (30)" }, - { color: "#ffff66", label: "Partial (60)" }, - { color: "#66ff66", label: "Validated (100)" }, + { 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" }, ], }, "threat-actor": { @@ -66,7 +74,11 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) { {/* Individual labels */} {legend.colors.map((item) => ( -
+
{item.label}
))} + + {/* Review Required — an overlay indicator (amber ring + ⚠️), not a + distinct score tier, so it's shown separately from the gradient. */} + {layerType === "coverage" && ( +
+
+ ⚠️ Review Required (any status) +
+ )}
); } diff --git a/frontend/src/pages/CompliancePage.tsx b/frontend/src/pages/CompliancePage.tsx index 22ee801..d0a7034 100644 --- a/frontend/src/pages/CompliancePage.tsx +++ b/frontend/src/pages/CompliancePage.tsx @@ -289,7 +289,7 @@ export default function CompliancePage() { {/* Summary cards */} {summary && ( -
+
{/* Gauge */}
diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx index c8f3c6c..a8ca420 100644 --- a/frontend/src/pages/ExecutiveDashboardPage.tsx +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -260,8 +260,11 @@ export default function ExecutiveDashboardPage() { }) .slice(0, 10); - // Official MITRE ATT&CK tactic order (slug → display label) - const MITRE_TACTIC_ORDER: Record = { + // Display labels for MITRE ATT&CK tactic slugs. Ordering itself comes + // pre-sorted from the backend (metrics_query_service.MITRE_TACTIC_ORDER), + // shared with the regular Dashboard's TacticCoverageChart so both views + // agree on tactic order without each reimplementing it. + const TACTIC_LABELS: Record = { "reconnaissance": "Reconnaissance", "resource-development": "Resource Development", "initial-access": "Initial Access", @@ -278,22 +281,17 @@ export default function ExecutiveDashboardPage() { "impact": "Impact", }; - const tacticDataRaw = (tacticCoverage || []).map((tc) => { + const tacticData = (tacticCoverage || []).map((tc) => { const slug = tc.tactic.toLowerCase(); - const label = MITRE_TACTIC_ORDER[slug] || + const label = TACTIC_LABELS[slug] || tc.tactic.split("-").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); - const order = Object.keys(MITRE_TACTIC_ORDER).indexOf(slug); return { name: label, slug, - order: order === -1 ? 99 : order, coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0, }; }); - // Sort by official MITRE order; include any unknown tactics at end - const tacticData = tacticDataRaw.sort((a, b) => a.order - b.order); - const getBarColor = (coverage: number) => { if (coverage < 30) return "#ef4444"; if (coverage < 50) return "#f97316"; @@ -313,20 +311,27 @@ export default function ExecutiveDashboardPage() {
{/* Time range filter */} -
- {TIME_RANGE_OPTIONS.map((opt) => ( - - ))} +
+
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+ {timeRange !== "all" && ( +

+ Applies to Team Performance & Operational KPIs only — other sections always show all-time data. +

+ )}
diff --git a/frontend/src/pages/TestCatalogPage.tsx b/frontend/src/pages/TestCatalogPage.tsx index b82db71..11e48de 100644 --- a/frontend/src/pages/TestCatalogPage.tsx +++ b/frontend/src/pages/TestCatalogPage.tsx @@ -10,6 +10,7 @@ import { ChevronRight, FlaskConical, X, + AlertTriangle, } from "lucide-react"; import { getTemplates } from "../api/test-templates"; import TestFromTemplateForm from "../components/TestFromTemplateForm"; @@ -349,6 +350,17 @@ function TemplateCard({ )}
+ {/* Duplicate-test warning */} + {template.existing_test_count > 0 && ( +
+ + {template.existing_test_count} existing test{template.existing_test_count !== 1 ? "s" : ""} for this technique +
+ )} + {/* Spacer */}
diff --git a/frontend/src/pages/TestsPage.tsx b/frontend/src/pages/TestsPage.tsx index 3bc79aa..78cdbb3 100644 --- a/frontend/src/pages/TestsPage.tsx +++ b/frontend/src/pages/TestsPage.tsx @@ -149,6 +149,8 @@ export default function TestsPage() { const [platformFilter, setPlatformFilter] = useState(""); const [searchText, setSearchText] = useState(""); const [showMyTasks, setShowMyTasks] = useState(false); + const [showMyReviews, setShowMyReviews] = useState(false); + const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead"; // ── Sort state ──────────────────────────────────────────────────── const [sortKey, setSortKey] = useState("created_at"); @@ -167,7 +169,13 @@ export default function TestsPage() { const filters = useMemo(() => { const f: TestListFilters = { limit: 200 }; - if (showMyTasks && user) { + if (showMyReviews && user && isReviewLead) { + // "My reviews" — tests assigned to ME at the red_review/blue_review + // gate. Distinct from "My Tasks" below, which covers the later + // in_review manager-validation stage regardless of assignment. + f.state = user.role === "red_lead" ? "red_review" : "blue_review"; + f.reviewer_id = user.id; + } else if (showMyTasks && user) { switch (user.role) { case "red_tech": f.created_by = user.id; @@ -192,7 +200,7 @@ export default function TestsPage() { if (platformFilter) f.platform = platformFilter; return f; - }, [stateFilter, platformFilter, showMyTasks, user]); + }, [stateFilter, platformFilter, showMyTasks, showMyReviews, isReviewLead, user]); const { data: allTests, @@ -497,7 +505,10 @@ export default function TestsPage() { )} + {/* My reviews toggle — red_lead/blue_lead only, the red_review/blue_review + lead-review gate assignment queue (distinct from "My Tasks" above, + which covers the later in_review manager-validation stage). */} + {isReviewLead && ( + + )} + {/* State filter */}
@@ -556,13 +590,14 @@ export default function TestsPage() {
{/* Clear filters */} - {(stateFilter || platformFilter || searchText || showMyTasks) && ( + {(stateFilter || platformFilter || searchText || showMyTasks || showMyReviews) && (
{/* Active filter summary */} - {(stateFilter || showMyTasks) && ( + {(stateFilter || showMyTasks || showMyReviews) && (
Showing: + {showMyReviews && ( + + My Reviews + + )} {showMyTasks && ( {myTasksLabel} @@ -629,11 +669,11 @@ export default function TestsPage() {

- {showMyTasks ? myTasksLabel : "All Tests"} + {showMyReviews ? "My Reviews" : showMyTasks ? myTasksLabel : "All Tests"}

{tests.length} tests
- +
)}
diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 91c3407..45efafa 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -203,6 +203,8 @@ export interface TestTemplateSummary { source: string; platform: string | null; severity: string | null; + /** Number of existing tests for this template's technique — used to warn before creating a duplicate. */ + existing_test_count: number; } // ── Timeline ───────────────────────────────────────────────────────