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:
Alan Buscaglia
2025-12-15 17:36:30 +01:00
parent b549c8dbad
commit dab231d626
10 changed files with 140 additions and 62 deletions
@@ -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">
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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>
);
})}
+52
View File
@@ -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(" ");
}
+1
View File
@@ -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;