diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index d212545421..21ccc62352 100755 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -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 diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 90697d7e31..4a830944b1 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) --- diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index fec6764ee1..ef97d31b41 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -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]) => { diff --git a/ui/app/(prowler)/new-overview/components/check-findings/check-findings.ssr.tsx b/ui/app/(prowler)/new-overview/components/check-findings/check-findings.ssr.tsx index 271f707dfe..20e0c5e77f 100644 --- a/ui/app/(prowler)/new-overview/components/check-findings/check-findings.ssr.tsx +++ b/ui/app/(prowler)/new-overview/components/check-findings/check-findings.ssr.tsx @@ -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 ( ); }; diff --git a/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart-detail.ssr.tsx b/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart-detail.ssr.tsx index f3aaaa10aa..a4de34e1a0 100644 --- a/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart-detail.ssr.tsx +++ b/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart-detail.ssr.tsx @@ -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} /> ); }; diff --git a/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart.tsx b/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart.tsx index 22f6fbb3b9..d592ddbadf 100644 --- a/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart.tsx +++ b/ui/app/(prowler)/new-overview/components/risk-severity-chart/risk-severity-chart.tsx @@ -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 = { + 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 = ({ - + ); diff --git a/ui/app/(prowler)/new-overview/components/status-chart/status-chart.tsx b/ui/app/(prowler)/new-overview/components/status-chart/status-chart.tsx index 1efb406975..78e1d2e47f 100644 --- a/ui/app/(prowler)/new-overview/components/status-chart/status-chart.tsx +++ b/ui/app/(prowler)/new-overview/components/status-chart/status-chart.tsx @@ -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} /> @@ -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" } diff --git a/ui/app/(prowler)/new-overview/components/threat-score/threat-score.tsx b/ui/app/(prowler)/new-overview/components/threat-score/threat-score.tsx index 84dcf9d837..e336f25402 100644 --- a/ui/app/(prowler)/new-overview/components/threat-score/threat-score.tsx +++ b/ui/app/(prowler)/new-overview/components/threat-score/threat-score.tsx @@ -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({

Threat score has{" "} @@ -178,8 +183,7 @@ export function ThreatScore({

Major gaps include {gaps.slice(0, 2).join(", ")} diff --git a/ui/components/graphs/donut-chart.tsx b/ui/components/graphs/donut-chart.tsx index 8bd0bf8181..54dfb406ce 100644 --- a/ui/components/graphs/donut-chart.tsx +++ b/ui/components/graphs/donut-chart.tsx @@ -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(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 ( setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)} + onClick={() => { + if (isClickable) { + onSegmentClick(data[index], index); + } + }} /> ); })} diff --git a/ui/components/graphs/horizontal-bar-chart.tsx b/ui/components/graphs/horizontal-bar-chart.tsx index 9b1291fa60..a708e0151d 100644 --- a/ui/components/graphs/horizontal-bar-chart.tsx +++ b/ui/components/graphs/horizontal-bar-chart.tsx @@ -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(null); @@ -58,12 +60,33 @@ export function HorizontalBarChart({ getSeverityColorByName(item.name) || "var(--bg-neutral-tertiary)"; + const isClickable = !isEmpty && onBarClick; return (

!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 */}