feat(ui): add Risk Radar component with category filtering (#9561)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2025-12-17 13:49:40 +01:00
committed by GitHub
parent 594188f7ed
commit c58ca136f0
14 changed files with 164 additions and 77 deletions

View File

@@ -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.
*/

View File

@@ -34,7 +34,7 @@ import type { BarDataPoint } from "@/components/graphs/types";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
// Threat Score colors (0-100 scale, higher = better)
// ThreatScore colors (0-100 scale, higher = better)
const THREAT_COLORS = {
DANGER: "var(--bg-fail-primary)", // 0-30
WARNING: "var(--bg-warning-primary)", // 31-60
@@ -100,7 +100,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => {
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
Threat Score
Prowler ThreatScore
</p>
<div className="mt-2">
<AlertPill value={y} />
@@ -268,8 +268,8 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
Risk Plot
</h3>
<p className="text-text-neutral-tertiary mt-1 text-xs">
Threat Score is severity-weighted, not quantity-based. Higher
severity findings have greater impact on the score.
Prowler ThreatScore is severity-weighted, not quantity-based.
Higher severity findings have greater impact on the score.
</p>
</div>
@@ -287,9 +287,9 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
<XAxis
type="number"
dataKey="x"
name="Threat Score"
name="Prowler ThreatScore"
label={{
value: "Threat Score",
value: "Prowler ThreatScore",
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
@@ -367,7 +367,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
{selectedPoint.name}
</h4>
<p className="text-text-neutral-tertiary text-xs">
Threat Score: {selectedPoint.x}% | Fail Findings:{" "}
Prowler ThreatScore: {selectedPoint.x}% | Fail Findings:{" "}
{selectedPoint.y}
</p>
</div>

View File

@@ -0,0 +1,46 @@
"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 === "" || value === "all") {
onCategoryChange(null);
} else {
onCategoryChange(value);
}
};
return (
<Select value={selectedCategory ?? "all"} onValueChange={handleValueChange}>
<SelectTrigger size="sm" className="w-[200px]">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{categories.map((category) => (
<SelectItem key={category.categoryId} value={category.categoryId}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -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">

View File

@@ -116,7 +116,7 @@ export function ThreatScore({
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
>
<CardHeader>
<CardTitle>Prowler Threat Score</CardTitle>
<CardTitle>Prowler ThreatScore</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
@@ -165,7 +165,7 @@ export function ThreatScore({
className="mt-0.5 min-h-4 min-w-4 shrink-0"
/>
<p>
Threat score has{" "}
Prowler ThreatScore has{" "}
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
{Math.abs(scoreDelta)}%
</p>
@@ -194,7 +194,7 @@ export function ThreatScore({
className="items-center justify-center"
>
<p className="text-text-neutral-secondary text-sm">
Threat Score Data Unavailable
Prowler ThreatScore Data Unavailable
</p>
</Card>
)}

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -61,6 +61,17 @@ export function HorizontalBarChart({
"var(--bg-neutral-tertiary)";
const isClickable = !isEmpty && onBarClick;
const maxValue =
data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0;
const calculatedWidth = isEmpty
? item.percentage
: (item.percentage ??
(maxValue > 0 ? (item.value / maxValue) * 100 : 0));
// Calculate display percentage (value / total * 100)
const displayPercentage = isEmpty
? 0
: (item.percentage ??
(total > 0 ? Math.round((item.value / total) * 100) : 0));
return (
<div
key={item.name}
@@ -105,15 +116,13 @@ export function HorizontalBarChart({
</div>
{/* Bar - flexible */}
<div className="relative flex-1">
<div className="relative h-[22px] flex-1">
<div className="bg-bg-neutral-tertiary absolute inset-0 h-[22px] w-full rounded-sm" />
{(item.value > 0 || isEmpty) && (
<div
className="relative h-[22px] rounded-sm border border-black/10 transition-all duration-300"
style={{
width: isEmpty
? `${item.percentage}%`
: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
width: `${calculatedWidth}%`,
backgroundColor: barColor,
opacity: isFaded ? 0.5 : 1,
}}
@@ -174,7 +183,7 @@ export function HorizontalBarChart({
}}
>
<span className="min-w-[26px] text-right font-medium">
{isEmpty ? "0" : item.percentage}%
{displayPercentage}%
</span>
<span className="shrink-0 font-medium"></span>
<span className="font-bold whitespace-nowrap">

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

View File

@@ -18,6 +18,7 @@ export const SEVERITY_ORDER = {
Medium: 2,
Low: 3,
Informational: 4,
Info: 4,
} as const;
export const LAYOUT_OPTIONS = {

View File

@@ -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>
);
})}

54
ui/lib/categories.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Special cases that don't follow standard capitalization rules.
* Add entries here for edge cases that heuristics can't handle.
*/
const SPECIAL_CASES: Record<string, string> = {
// Add special cases here if needed, e.g.:
// "someweirdcase": "SomeWeirdCase",
};
/**
* Converts a category ID to a human-readable label.
*
* Capitalization rules (in order of priority):
* 1. Special cases dictionary - for edge cases that don't follow patterns
* 2. Acronym + version pattern (e.g., imdsv1 -> IMDSv1, apiv2 -> APIv2)
* 3. Short words (≤3 chars) - fully capitalized (e.g., iam -> IAM, ec2 -> EC2)
* 4. Default - capitalize first letter (e.g., internet -> Internet)
*
* Examples:
* - "internet-exposed" -> "Internet Exposed"
* - "iam" -> "IAM"
* - "ec2-imdsv1" -> "EC2 IMDSv1"
* - "forensics-ready" -> "Forensics Ready"
*/
export function getCategoryLabel(id: string): string {
return id
.split("-")
.map((word) => formatWord(word))
.join(" ");
}
function formatWord(word: string): string {
const lowerWord = word.toLowerCase();
// 1. Check special cases dictionary
if (lowerWord in SPECIAL_CASES) {
return SPECIAL_CASES[lowerWord];
}
// 2. Acronym + version pattern (e.g., imdsv1 -> IMDSv1)
const versionMatch = lowerWord.match(/^([a-z]+)(v\d+)$/);
if (versionMatch) {
const [, acronym, version] = versionMatch;
return acronym.toUpperCase() + version.toLowerCase();
}
// 3. Short words are likely acronyms (IAM, EC2, S3, API, VPC, etc.)
if (word.length <= 3) {
return word.toUpperCase();
}
// 4. Default: capitalize first letter
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}

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;