From dab231d6266ce639e8b952e5ee42e8714215aaee Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Mon, 15 Dec 2025 17:36:30 +0100 Subject: [PATCH] feat(ui): add category selector for risk radar and findings filters - Add CategorySelector component to risk radar view for filtering by category - Add fade effect to non-selected radar chart points - Add category filter to findings page using shared labelFormatter - Extract category labels to shared lib/categories.ts - Remove category from active filter badges (now handled by select) --- .../overview/risk-radar/risk-radar.adapter.ts | 54 +------------------ .../risk-radar-view/category-selector.tsx | 49 +++++++++++++++++ .../risk-radar-view-client.tsx | 16 ++++++ ui/app/(prowler)/findings/page.tsx | 4 +- ui/components/filters/active-filter-badge.tsx | 5 -- ui/components/findings/findings-filters.tsx | 10 ++++ ui/components/graphs/radar-chart.tsx | 4 +- .../ui/table/data-table-filter-custom.tsx | 7 ++- ui/lib/categories.ts | 52 ++++++++++++++++++ ui/types/filters.ts | 1 + 10 files changed, 140 insertions(+), 62 deletions(-) create mode 100644 ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/category-selector.tsx create mode 100644 ui/lib/categories.ts diff --git a/ui/actions/overview/risk-radar/risk-radar.adapter.ts b/ui/actions/overview/risk-radar/risk-radar.adapter.ts index 4de4e259f5..0859d434e5 100644 --- a/ui/actions/overview/risk-radar/risk-radar.adapter.ts +++ b/ui/actions/overview/risk-radar/risk-radar.adapter.ts @@ -1,60 +1,8 @@ import type { RadarDataPoint } from "@/components/graphs/types"; +import { getCategoryLabel } from "@/lib/categories"; import { CategoryOverview, CategoryOverviewResponse } from "./types"; -// Category IDs from the API -const CATEGORY_IDS = { - E3: "e3", - E5: "e5", - ENCRYPTION: "encryption", - FORENSICS_READY: "forensics-ready", - IAM: "iam", - INTERNET_EXPOSED: "internet-exposed", - LOGGING: "logging", - NETWORK: "network", - PUBLICLY_ACCESSIBLE: "publicly-accessible", - SECRETS: "secrets", - STORAGE: "storage", - THREAT_DETECTION: "threat-detection", - TRUSTBOUNDARIES: "trustboundaries", - UNUSED: "unused", -} as const; - -export type CategoryId = (typeof CATEGORY_IDS)[keyof typeof CATEGORY_IDS]; - -// Human-readable labels for category IDs -const CATEGORY_LABELS: Record = { - [CATEGORY_IDS.E3]: "E3", - [CATEGORY_IDS.E5]: "E5", - [CATEGORY_IDS.ENCRYPTION]: "Encryption", - [CATEGORY_IDS.FORENSICS_READY]: "Forensics Ready", - [CATEGORY_IDS.IAM]: "IAM", - [CATEGORY_IDS.INTERNET_EXPOSED]: "Internet Exposed", - [CATEGORY_IDS.LOGGING]: "Logging", - [CATEGORY_IDS.NETWORK]: "Network", - [CATEGORY_IDS.PUBLICLY_ACCESSIBLE]: "Publicly Accessible", - [CATEGORY_IDS.SECRETS]: "Secrets", - [CATEGORY_IDS.STORAGE]: "Storage", - [CATEGORY_IDS.THREAT_DETECTION]: "Threat Detection", - [CATEGORY_IDS.TRUSTBOUNDARIES]: "Trust Boundaries", - [CATEGORY_IDS.UNUSED]: "Unused", -}; - -/** - * Converts a category ID to a human-readable label. - * Falls back to capitalizing the ID if not found in the mapping. - */ -function getCategoryLabel(id: string): string { - if (CATEGORY_LABELS[id]) { - return CATEGORY_LABELS[id]; - } - // Fallback: capitalize and replace hyphens with spaces - return id - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - /** * Calculates the percentage of new failed findings relative to total failed findings. */ diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/category-selector.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/category-selector.tsx new file mode 100644 index 0000000000..d4ef88f455 --- /dev/null +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/category-selector.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { RadarDataPoint } from "@/components/graphs/types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; + +interface CategorySelectorProps { + categories: RadarDataPoint[]; + selectedCategory: string | null; + onCategoryChange: (categoryId: string | null) => void; +} + +export function CategorySelector({ + categories, + selectedCategory, + onCategoryChange, +}: CategorySelectorProps) { + const handleValueChange = (value: string) => { + if (value === "") { + onCategoryChange(null); + } else { + onCategoryChange(value); + } + }; + + return ( + + ); +} diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx index 2c8cb8c989..5cd079ed2f 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx @@ -9,6 +9,8 @@ import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types"; import { Card } from "@/components/shadcn/card/card"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; +import { CategorySelector } from "./category-selector"; + interface RiskRadarViewClientProps { data: RadarDataPoint[]; } @@ -24,6 +26,15 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) { setSelectedPoint(point); }; + const handleCategoryChange = (categoryId: string | null) => { + if (categoryId === null) { + setSelectedPoint(null); + } else { + const point = data.find((d) => d.categoryId === categoryId); + setSelectedPoint(point ?? null); + } + }; + const handleBarClick = (dataPoint: BarDataPoint) => { if (!selectedPoint) return; @@ -59,6 +70,11 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {

Risk Radar

+
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index a25abfe411..1b4911c5c1 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -53,11 +53,12 @@ export default async function Findings({ getScans({ pageSize: 50 }), ]); - // Extract unique regions and services from the new endpoint + // Extract unique regions, services, categories from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = metadataInfoData?.data?.attributes?.resource_types || []; + const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; // Extract provider IDs and details using helper functions const providerIds = providersData ? extractProviderIds(providersData) : []; @@ -93,6 +94,7 @@ export default async function Findings({ uniqueRegions={uniqueRegions} uniqueServices={uniqueServices} uniqueResourceTypes={uniqueResourceTypes} + uniqueCategories={uniqueCategories} /> }> diff --git a/ui/components/filters/active-filter-badge.tsx b/ui/components/filters/active-filter-badge.tsx index 9e0327d598..7c78e8095e 100644 --- a/ui/components/filters/active-filter-badge.tsx +++ b/ui/components/filters/active-filter-badge.tsx @@ -43,11 +43,6 @@ export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [ label: "Check ID", formatMultiple: (count) => `${count} Check IDs filtered`, }, - { - filterKey: "category__in", - label: "Category", - formatMultiple: (count) => `${count} Categories filtered`, - }, { filterKey: "scan__in", label: "Scan", diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 90cdf354a1..8bf51da877 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -3,6 +3,7 @@ import { filterFindings } from "@/components/filters/data-filters"; import { FilterControls } from "@/components/filters/filter-controls"; import { useRelatedFilters } from "@/hooks"; +import { getCategoryLabel } from "@/lib/categories"; import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types"; interface FindingsFiltersProps { @@ -14,6 +15,7 @@ interface FindingsFiltersProps { uniqueRegions: string[]; uniqueServices: string[]; uniqueResourceTypes: string[]; + uniqueCategories: string[]; } export const FindingsFilters = ({ @@ -24,6 +26,7 @@ export const FindingsFilters = ({ uniqueRegions, uniqueServices, uniqueResourceTypes, + uniqueCategories, }: FindingsFiltersProps) => { const { availableProviderIds, availableScans } = useRelatedFilters({ providerIds, @@ -66,6 +69,13 @@ export const FindingsFilters = ({ values: uniqueResourceTypes, index: 8, }, + { + key: FilterType.CATEGORY, + labelCheckboxGroup: "Category", + values: uniqueCategories, + labelFormatter: getCategoryLabel, + index: 5, + }, { key: FilterType.SCAN, labelCheckboxGroup: "Scan ID", diff --git a/ui/components/graphs/radar-chart.tsx b/ui/components/graphs/radar-chart.tsx index f5beb535c1..6acd773e39 100644 --- a/ui/components/graphs/radar-chart.tsx +++ b/ui/components/graphs/radar-chart.tsx @@ -98,6 +98,7 @@ const CustomDot = ({ }: CustomDotProps) => { const currentCategory = payload.name || payload.category; const isSelected = selectedPoint?.category === currentCategory; + const isFaded = selectedPoint !== null && !isSelected; const handleClick = (e: MouseEvent) => { e.stopPropagation(); @@ -127,13 +128,14 @@ const CustomDot = ({ cx={cx} cy={cy} r={isSelected ? 9 : 6} - fillOpacity={1} style={{ fill: isSelected ? "var(--bg-button-primary)" : "var(--bg-radar-button)", + fillOpacity: isFaded ? 0.3 : 1, cursor: onSelectPoint ? "pointer" : "default", pointerEvents: "all", + transition: "fill-opacity 200ms ease-in-out", }} onClick={onSelectPoint ? handleClick : undefined} /> diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index 890fb62c23..7ae59a648e 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -151,13 +151,16 @@ export const DataTableFilterCustom = ({ {filter.values.map((value) => { const entity = getEntityForValue(filter, value); + const displayLabel = filter.labelFormatter + ? filter.labelFormatter(value) + : value; return ( - {entity ? renderEntityContent(entity) : value} + {entity ? renderEntityContent(entity) : displayLabel} ); })} diff --git a/ui/lib/categories.ts b/ui/lib/categories.ts new file mode 100644 index 0000000000..271c57ab12 --- /dev/null +++ b/ui/lib/categories.ts @@ -0,0 +1,52 @@ +// Category IDs from the API +export const CATEGORY_IDS = { + E3: "e3", + E5: "e5", + ENCRYPTION: "encryption", + FORENSICS_READY: "forensics-ready", + IAM: "iam", + INTERNET_EXPOSED: "internet-exposed", + LOGGING: "logging", + NETWORK: "network", + PUBLICLY_ACCESSIBLE: "publicly-accessible", + SECRETS: "secrets", + STORAGE: "storage", + THREAT_DETECTION: "threat-detection", + TRUSTBOUNDARIES: "trustboundaries", + UNUSED: "unused", +} as const; + +export type CategoryId = (typeof CATEGORY_IDS)[keyof typeof CATEGORY_IDS]; + +// Human-readable labels for category IDs +export const CATEGORY_LABELS: Record = { + [CATEGORY_IDS.E3]: "E3", + [CATEGORY_IDS.E5]: "E5", + [CATEGORY_IDS.ENCRYPTION]: "Encryption", + [CATEGORY_IDS.FORENSICS_READY]: "Forensics Ready", + [CATEGORY_IDS.IAM]: "IAM", + [CATEGORY_IDS.INTERNET_EXPOSED]: "Internet Exposed", + [CATEGORY_IDS.LOGGING]: "Logging", + [CATEGORY_IDS.NETWORK]: "Network", + [CATEGORY_IDS.PUBLICLY_ACCESSIBLE]: "Publicly Accessible", + [CATEGORY_IDS.SECRETS]: "Secrets", + [CATEGORY_IDS.STORAGE]: "Storage", + [CATEGORY_IDS.THREAT_DETECTION]: "Threat Detection", + [CATEGORY_IDS.TRUSTBOUNDARIES]: "Trust Boundaries", + [CATEGORY_IDS.UNUSED]: "Unused", +}; + +/** + * Converts a category ID to a human-readable label. + * Falls back to capitalizing the ID if not found in the mapping. + */ +export function getCategoryLabel(id: string): string { + if (CATEGORY_LABELS[id]) { + return CATEGORY_LABELS[id]; + } + // Fallback: capitalize and replace hyphens with spaces + return id + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/ui/types/filters.ts b/ui/types/filters.ts index 6585f379a2..126ef1f82e 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -11,6 +11,7 @@ export interface FilterOption { labelCheckboxGroup: string; values: string[]; valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>; + labelFormatter?: (value: string) => string; index?: number; showSelectAll?: boolean; defaultToSelectAll?: boolean;