feat(ui/overview): add click navigation for charts and threat score improvements (#9281)

This commit is contained in:
Alan Buscaglia
2025-11-20 18:47:42 +01:00
committed by GitHub
parent 46bfe02ee8
commit 58bb66ff27
10 changed files with 226 additions and 61 deletions

View File

@@ -48,23 +48,21 @@ if [ "$CODE_REVIEW_ENABLED" = "true" ]; then
echo -e "${YELLOW}🔍 Running Claude Code standards validation...${NC}"
echo ""
echo -e "${BLUE}📋 Files to validate:${NC}"
echo "$STAGED_FILES" | sed 's/^/ - /'
echo "$STAGED_FILES" | while IFS= read -r file; do echo " - $file"; done
echo ""
echo -e "${BLUE}📤 Sending to Claude Code for validation...${NC}"
echo ""
# Build prompt with git diff of changes AND full context
# Build prompt with full file contents
VALIDATION_PROMPT=$(
cat <<'PROMPT_EOF'
You are a code reviewer for the Prowler UI project. Analyze the code changes (git diff with full context) below and validate they comply with AGENTS.md standards.
**CRITICAL: You MUST check BOTH the changed lines AND the surrounding context for violations.**
You are a code reviewer for the Prowler UI project. Analyze the full file contents of changed files below and validate they comply with AGENTS.md standards.
**RULES TO CHECK:**
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes.
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
5. React 19: NO `useMemo`/`useCallback` without reason
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
@@ -76,24 +74,28 @@ You are a code reviewer for the Prowler UI project. Analyze the code changes (gi
12. Use the components inside components/shadcn if possible
13. Check Accessibility best practices (like alt tags in images, semantic HTML, Aria labels, etc.)
=== GIT DIFF WITH CONTEXT ===
=== FILES TO REVIEW ===
PROMPT_EOF
)
# Add git diff to prompt with more context (U5 = 5 lines before/after)
VALIDATION_PROMPT="$VALIDATION_PROMPT
$(git diff --cached -U5)"
# Add full file contents for each staged file
for file in $STAGED_FILES; do
VALIDATION_PROMPT="$VALIDATION_PROMPT
=== FILE: $file ===
$(cat "$file" 2>/dev/null || echo "Error reading file")"
done
VALIDATION_PROMPT="$VALIDATION_PROMPT
=== END DIFF ===
=== END FILES ===
**IMPORTANT: Your response MUST start with exactly one of these lines:**
STATUS: PASSED
STATUS: FAILED
**If FAILED:** List each violation with File, Line Number, Rule Number, and Issue.
**If PASSED:** Confirm all visible code (including context) complies with AGENTS.md standards.
**If PASSED:** Confirm all files comply with AGENTS.md standards.
**Start your response now with STATUS:**"
@@ -149,3 +151,19 @@ else
echo ""
exit 1
fi
# Run build
echo -e "${BLUE}🔨 Running build...${NC}"
echo ""
if npm run build; then
echo ""
echo -e "${GREEN}✅ Build passed${NC}"
echo ""
else
echo ""
echo -e "${RED}❌ Build failed${NC}"
echo -e "${RED}Fix build errors before committing${NC}"
echo ""
exit 1
fi

View File

@@ -19,6 +19,8 @@ All notable changes to the **Prowler UI** are documented in this file.
- Resource ID moved up in the findings detail page [(#9141)](https://github.com/prowler-cloud/prowler/pull/9141)
- C5 compliance logo [(#9224)](https://github.com/prowler-cloud/prowler/pull/9224)
- Overview charts now support click navigation to Findings page with filters and keyboard accessibility [(#9281)](https://github.com/prowler-cloud/prowler/pull/9281)
- Threat score now displays 2 decimal places with note that it doesn't include muted findings [(#9281)](https://github.com/prowler-cloud/prowler/pull/9281)
---

View File

@@ -95,7 +95,6 @@ export const getFindingsBySeverity = async ({
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters, but exclude unsupported filters
// The overviews/findings_severity endpoint does not support status or muted filters
Object.entries(filters).forEach(([key, value]) => {

View File

@@ -1,4 +1,5 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -11,7 +12,10 @@ export const CheckFindingsSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
const [findingsByStatus, providersData] = await Promise.all([
getFindingsByStatus({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
if (!findingsByStatus) {
return (
@@ -21,29 +25,21 @@ export const CheckFindingsSSR = async ({
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const attributes = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
const { fail = 0, pass = 0, fail_new = 0, pass_new = 0 } = attributes;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
providers={providersData?.data}
/>
);
};

View File

@@ -1,4 +1,5 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -11,7 +12,10 @@ export const RiskSeverityChartDetailSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
const [findingsBySeverity, providersData] = await Promise.all([
getFindingsBySeverity({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
if (!findingsBySeverity) {
return (
@@ -36,6 +40,7 @@ export const RiskSeverityChartDetailSSR = async ({
medium={medium}
low={low}
informational={informational}
providers={providersData?.data}
/>
);
};

View File

@@ -1,5 +1,7 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { BarDataPoint } from "@/components/graphs/types";
import {
@@ -11,12 +13,23 @@ import {
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface ProviderAttributes {
uid: string;
provider: string;
}
interface Provider {
id: string;
attributes: ProviderAttributes;
}
interface RiskSeverityChartProps {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
providers?: Provider[];
}
export const RiskSeverityChart = ({
@@ -25,7 +38,55 @@ export const RiskSeverityChart = ({
medium,
low,
informational,
providers = [],
}: RiskSeverityChartProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const handleBarClick = (dataPoint: BarDataPoint) => {
// Build the URL with current filters plus severity and muted
const params = new URLSearchParams(searchParams.toString());
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
const providerIds = params.get("filter[provider_id__in]");
if (providerIds) {
params.delete("filter[provider_id__in]");
// Remove provider_type__in since provider_id__in is more specific
params.delete("filter[provider_type__in]");
const ids = providerIds.split(",");
const uids = ids
.map((id) => {
const provider = providers.find((p) => p.id === id);
return provider?.attributes.uid;
})
.filter(Boolean);
if (uids.length > 0) {
params.set("filter[provider_uid__in]", uids.join(","));
}
}
// Map severity name to lowercase for the filter
const severityMap: Record<string, string> = {
Critical: "critical",
High: "high",
Medium: "medium",
Low: "low",
Info: "informational",
};
const severity = severityMap[dataPoint.name];
if (severity) {
params.set("filter[severity__in]", severity);
}
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
// Calculate total findings
const totalFindings = critical + high + medium + low + informational;
@@ -68,7 +129,7 @@ export const RiskSeverityChart = ({
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start px-6">
<HorizontalBarChart data={chartData} />
<HorizontalBarChart data={chartData} onBarClick={handleBarClick} />
</CardContent>
</Card>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
import { Bell, ShieldCheck, TriangleAlert } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { DonutChart } from "@/components/graphs/donut-chart";
import { DonutDataPoint } from "@/components/graphs/types";
@@ -15,26 +16,76 @@ import {
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface FindingsData {
total: number;
new: number;
}
interface ProviderAttributes {
uid: string;
provider: string;
}
interface Provider {
id: string;
attributes: ProviderAttributes;
}
interface StatusChartProps {
failFindingsData: {
total: number;
new: number;
muted: number;
};
passFindingsData: {
total: number;
new: number;
muted: number;
};
failFindingsData: FindingsData;
passFindingsData: FindingsData;
providers?: Provider[];
}
export const StatusChart = ({
failFindingsData,
passFindingsData,
providers = [],
}: StatusChartProps) => {
// Calculate total findings
const router = useRouter();
const searchParams = useSearchParams();
// Calculate total from displayed findings (fail + pass)
const totalFindings = failFindingsData.total + passFindingsData.total;
const handleSegmentClick = (dataPoint: DonutDataPoint) => {
// Build the URL with current filters plus status and muted
const params = new URLSearchParams(searchParams.toString());
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
const providerIds = params.get("filter[provider_id__in]");
if (providerIds) {
params.delete("filter[provider_id__in]");
// Remove provider_type__in since provider_id__in is more specific
params.delete("filter[provider_type__in]");
const ids = providerIds.split(",");
const uids = ids
.map((id) => {
const provider = providers.find((p) => p.id === id);
return provider?.attributes.uid;
})
.filter(Boolean);
if (uids.length > 0) {
params.set("filter[provider_uid__in]", uids.join(","));
}
}
// Add status filter based on which segment was clicked
if (dataPoint.name === "Fail Findings") {
params.set("filter[status__in]", "FAIL");
} else if (dataPoint.name === "Pass Findings") {
params.set("filter[status__in]", "PASS");
}
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
// Calculate percentages
const failPercentage = calculatePercentage(
failFindingsData.total,
@@ -93,6 +144,7 @@ export const StatusChart = ({
value: totalFindings.toLocaleString(),
label: "Total Findings",
}}
onSegmentClick={handleSegmentClick}
/>
</div>
@@ -109,10 +161,7 @@ export const StatusChart = ({
variant: CardVariant.fail,
}}
label="Fail Findings"
stats={[
{ icon: Bell, label: `${failFindingsData.new} New` },
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
]}
stats={[{ icon: Bell, label: `${failFindingsData.new} New` }]}
emptyState={
failFindingsData.total === 0
? { message: "No failed findings to display" }
@@ -133,10 +182,7 @@ export const StatusChart = ({
variant: CardVariant.pass,
}}
label="Pass Findings"
stats={[
{ icon: Bell, label: `${passFindingsData.new} New` },
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
]}
stats={[{ icon: Bell, label: `${passFindingsData.new} New` }]}
emptyState={
passFindingsData.total === 0
? { message: "No passed findings to display" }

View File

@@ -15,25 +15,31 @@ import {
Skeleton,
} from "@/components/shadcn";
// CSS variables are required here as they're passed to RadialChart component
// which uses Recharts library that needs actual color values, not Tailwind classes
const THREAT_COLORS = {
DANGER: "var(--bg-fail-primary)",
WARNING: "var(--bg-warning-primary)",
SUCCESS: "var(--bg-pass-primary)",
NEUTRAL: "var(--bg-neutral-tertiary)",
} as const;
const THREAT_LEVEL_CONFIG = {
DANGER: {
label: "Critical Risk",
color: "var(--bg-fail-primary)",
chartColor: "var(--bg-fail-primary)",
color: THREAT_COLORS.DANGER,
minScore: 0,
maxScore: 30,
},
WARNING: {
label: "Moderate Risk",
color: "var(--bg-warning-primary)",
chartColor: "var(--bg-warning-primary)",
color: THREAT_COLORS.WARNING,
minScore: 31,
maxScore: 60,
},
SUCCESS: {
label: "Secure",
color: "var(--bg-pass-primary)",
chartColor: "var(--bg-pass-primary)",
color: THREAT_COLORS.SUCCESS,
minScore: 61,
maxScore: 100,
},
@@ -68,7 +74,7 @@ function convertSectionScoresToTooltipData(
return Object.entries(sectionScores).map(([name, value]) => {
// Determine color based on the same ranges as THREAT_LEVEL_CONFIG
const threatLevel = getThreatLevel(value);
const color = THREAT_LEVEL_CONFIG[threatLevel].chartColor;
const color = THREAT_LEVEL_CONFIG[threatLevel].color;
return { name, value, color };
});
@@ -126,8 +132,8 @@ export function ThreatScore({
<RadialChart
percentage={displayScore}
label="Score"
color={config.chartColor}
backgroundColor="var(--bg-neutral-tertiary)"
color={config.color}
backgroundColor={THREAT_COLORS.NEUTRAL}
height={206}
innerRadius={90}
outerRadius={115}
@@ -162,8 +168,7 @@ export function ThreatScore({
<div className="flex items-start gap-1.5">
<ThumbsUp
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
className="mt-0.5 min-h-4 min-w-4 shrink-0"
/>
<p>
Threat score has{" "}
@@ -178,8 +183,7 @@ export function ThreatScore({
<div className="flex items-start gap-1.5">
<MessageCircleWarning
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
className="mt-0.5 min-h-4 min-w-4 shrink-0"
/>
<p>
Major gaps include {gaps.slice(0, 2).join(", ")}

View File

@@ -18,6 +18,7 @@ interface DonutChartProps {
value: string | number;
label: string;
};
onSegmentClick?: (dataPoint: DonutDataPoint, index: number) => void;
}
const CustomTooltip = ({ active, payload }: any) => {
@@ -72,6 +73,7 @@ export function DonutChart({
outerRadius = 86,
showLegend = true,
centerLabel,
onSegmentClick,
}: DonutChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@@ -137,14 +139,23 @@ export function DonutChart({
{(isEmpty ? emptyData : chartData).map((entry, index) => {
const opacity =
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
const isClickable = !isEmpty && onSegmentClick;
return (
<Cell
key={`cell-${index}`}
fill={entry.fill}
opacity={opacity}
style={{ transition: "opacity 0.2s" }}
style={{
transition: "opacity 0.2s",
cursor: isClickable ? "pointer" : "default",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
if (isClickable) {
onSegmentClick(data[index], index);
}
}}
/>
);
})}

View File

@@ -12,12 +12,14 @@ interface HorizontalBarChartProps {
height?: number;
title?: string;
labelWidth?: string;
onBarClick?: (dataPoint: BarDataPoint, index: number) => void;
}
export function HorizontalBarChart({
data,
title,
labelWidth = "w-20",
onBarClick,
}: HorizontalBarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@@ -58,12 +60,33 @@ export function HorizontalBarChart({
getSeverityColorByName(item.name) ||
"var(--bg-neutral-tertiary)";
const isClickable = !isEmpty && onBarClick;
return (
<div
key={item.name}
className="flex items-center gap-10"
style={{ cursor: isClickable ? "pointer" : "default" }}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
onClick={() => {
if (isClickable) {
const originalIndex = data.findIndex(
(d) => d.name === item.name,
);
onBarClick(data[originalIndex], originalIndex);
}
}}
onKeyDown={(e) => {
if (isClickable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
const originalIndex = data.findIndex(
(d) => d.name === item.name,
);
onBarClick(data[originalIndex], originalIndex);
}
}}
>
{/* Label */}
<div className={`w-20 md:${labelWidth} shrink-0`}>