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
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:
@@ -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" />
|
||||
|
||||
@@ -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 & Operational KPIs only — other sections always show all-time data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user