feat(frontend): My Reviews queue, duplicate-test badge, tactic order, legend + tooltip fixes
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

- 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
This commit is contained in:
kitos
2026-07-08 08:46:24 +02:00
parent 1c8fa436ab
commit 8cf8001de5
9 changed files with 125 additions and 43 deletions
+3
View File
@@ -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<Test[]> {
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));
+1 -1
View File
@@ -25,7 +25,7 @@ const BADGE_LABELS: Record<TechniqueStatus, string> = {
interface TooltipLine { label: string; text: string }
const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
export const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
validated: {
heading: "✅ Validated",
lines: [
@@ -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 (
<>
<Fragment key={control.control_id}>
{/* Main row */}
<tr
key={control.control_id}
className={`cursor-pointer transition-colors hover:bg-gray-800/40 ${
isExpanded ? "bg-gray-800/20" : ""
}`}
@@ -256,7 +255,7 @@ export default function ControlsTable({ controls }: ControlsTableProps) {
</td>
</tr>
)}
</>
</Fragment>
);
})}
</tbody>
@@ -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) => (
<div key={item.label} className="flex items-center gap-1.5">
<div
key={item.label}
className="flex items-center gap-1.5"
title={item.status ? tooltipText(item.status) : undefined}
>
<div
className="h-3 w-3 rounded border border-gray-700"
style={{ backgroundColor: item.color }}
@@ -74,6 +86,15 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
<span className="text-xs text-gray-400">{item.label}</span>
</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>
)}
</div>
);
}
+1 -1
View File
@@ -289,7 +289,7 @@ export default function CompliancePage() {
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{/* Gauge */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
+28 -23
View File
@@ -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<string, string> = {
// 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<string, string> = {
"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() {
</div>
{/* Time range filter */}
<div className="flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900 p-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
timeRange === opt.value
? "bg-cyan-600 text-white"
: "text-gray-400 hover:text-white"
}`}
>
{opt.label}
</button>
))}
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900 p-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
timeRange === opt.value
? "bg-cyan-600 text-white"
: "text-gray-400 hover:text-white"
}`}
>
{opt.label}
</button>
))}
</div>
{timeRange !== "all" && (
<p className="text-[10px] text-gray-500">
Applies to Team Performance &amp; Operational KPIs only other sections always show all-time data.
</p>
)}
</div>
</div>
+12
View File
@@ -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({
)}
</div>
{/* Duplicate-test warning */}
{template.existing_test_count > 0 && (
<div
className="mt-3 flex items-center gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-400"
title={`${template.existing_test_count} existing test(s) already cover ${template.mitre_technique_id}`}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{template.existing_test_count} existing test{template.existing_test_count !== 1 ? "s" : ""} for this technique
</div>
)}
{/* Spacer */}
<div className="flex-1" />
+47 -7
View File
@@ -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<SortKey>("created_at");
@@ -167,7 +169,13 @@ export default function TestsPage() {
const filters = useMemo<TestListFilters>(() => {
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() {
<button
onClick={() => {
setShowMyTasks(!showMyTasks);
if (!showMyTasks) setStateFilter("");
if (!showMyTasks) {
setShowMyReviews(false);
setStateFilter("");
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyTasks
@@ -510,6 +521,29 @@ export default function TestsPage() {
</button>
)}
{/* 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 && (
<button
onClick={() => {
setShowMyReviews(!showMyReviews);
if (!showMyReviews) {
setShowMyTasks(false);
setStateFilter("");
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyReviews
? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
}`}
>
<Eye className="h-4 w-4" />
My Reviews
</button>
)}
{/* State filter */}
<div className="flex items-center gap-1.5">
<Filter className="h-4 w-4 text-gray-500" />
@@ -556,13 +590,14 @@ export default function TestsPage() {
</div>
{/* Clear filters */}
{(stateFilter || platformFilter || searchText || showMyTasks) && (
{(stateFilter || platformFilter || searchText || showMyTasks || showMyReviews) && (
<button
onClick={() => {
setStateFilter("");
setPlatformFilter("");
setSearchText("");
setShowMyTasks(false);
setShowMyReviews(false);
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
@@ -572,9 +607,14 @@ export default function TestsPage() {
</div>
{/* Active filter summary */}
{(stateFilter || showMyTasks) && (
{(stateFilter || showMyTasks || showMyReviews) && (
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
<span>Showing:</span>
{showMyReviews && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
My Reviews
</span>
)}
{showMyTasks && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
{myTasksLabel}
@@ -629,11 +669,11 @@ export default function TestsPage() {
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{showMyTasks ? myTasksLabel : "All Tests"}
{showMyReviews ? "My Reviews" : showMyTasks ? myTasksLabel : "All Tests"}
</h2>
<span className="text-sm text-gray-400">{tests.length} tests</span>
</div>
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyReviews ? "No tests currently assigned to you for review." : showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
</div>
)}
</div>
+2
View File
@@ -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 ───────────────────────────────────────────────────────