mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
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)
This commit is contained in:
@@ -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<string, string> = {
|
||||
[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.
|
||||
*/
|
||||
|
||||
@@ -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 (
|
||||
<Select
|
||||
value={selectedCategory ?? ""}
|
||||
onValueChange={handleValueChange}
|
||||
allowDeselect
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[200px]">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.categoryId} value={category.categoryId}>
|
||||
{category.category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
<h3 className="text-neutral-primary text-lg font-semibold">
|
||||
Risk Radar
|
||||
</h3>
|
||||
<CategorySelector
|
||||
categories={data}
|
||||
selectedCategory={selectedPoint?.categoryId ?? null}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[400px] w-full flex-1">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -151,13 +151,16 @@ export const DataTableFilterCustom = ({
|
||||
<MultiSelectSeparator />
|
||||
{filter.values.map((value) => {
|
||||
const entity = getEntityForValue(filter, value);
|
||||
const displayLabel = filter.labelFormatter
|
||||
? filter.labelFormatter(value)
|
||||
: value;
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
badgeLabel={getBadgeLabel(entity, value)}
|
||||
badgeLabel={getBadgeLabel(entity, displayLabel)}
|
||||
>
|
||||
{entity ? renderEntityContent(entity) : value}
|
||||
{entity ? renderEntityContent(entity) : displayLabel}
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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(" ");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user