mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat(ui): add Risk Radar component with category filtering (#9561)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -1,60 +1,8 @@
|
|||||||
import type { RadarDataPoint } from "@/components/graphs/types";
|
import type { RadarDataPoint } from "@/components/graphs/types";
|
||||||
|
import { getCategoryLabel } from "@/lib/categories";
|
||||||
|
|
||||||
import { CategoryOverview, CategoryOverviewResponse } from "./types";
|
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.
|
* Calculates the percentage of new failed findings relative to total failed findings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type { BarDataPoint } from "@/components/graphs/types";
|
|||||||
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
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 = {
|
const THREAT_COLORS = {
|
||||||
DANGER: "var(--bg-fail-primary)", // 0-30
|
DANGER: "var(--bg-fail-primary)", // 0-30
|
||||||
WARNING: "var(--bg-warning-primary)", // 31-60
|
WARNING: "var(--bg-warning-primary)", // 31-60
|
||||||
@@ -100,7 +100,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-text-neutral-secondary text-sm font-medium">
|
<p className="text-text-neutral-secondary text-sm font-medium">
|
||||||
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
|
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
|
||||||
Threat Score
|
Prowler ThreatScore
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<AlertPill value={y} />
|
<AlertPill value={y} />
|
||||||
@@ -268,8 +268,8 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
Risk Plot
|
Risk Plot
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||||
Threat Score is severity-weighted, not quantity-based. Higher
|
Prowler ThreatScore is severity-weighted, not quantity-based.
|
||||||
severity findings have greater impact on the score.
|
Higher severity findings have greater impact on the score.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,9 +287,9 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="x"
|
dataKey="x"
|
||||||
name="Threat Score"
|
name="Prowler ThreatScore"
|
||||||
label={{
|
label={{
|
||||||
value: "Threat Score",
|
value: "Prowler ThreatScore",
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
offset: 10,
|
offset: 10,
|
||||||
fill: "var(--color-text-neutral-secondary)",
|
fill: "var(--color-text-neutral-secondary)",
|
||||||
@@ -367,7 +367,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
{selectedPoint.name}
|
{selectedPoint.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-text-neutral-tertiary text-xs">
|
<p className="text-text-neutral-tertiary text-xs">
|
||||||
Threat Score: {selectedPoint.x}% | Fail Findings:{" "}
|
Prowler ThreatScore: {selectedPoint.x}% | Fail Findings:{" "}
|
||||||
{selectedPoint.y}
|
{selectedPoint.y}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types";
|
|||||||
import { Card } from "@/components/shadcn/card/card";
|
import { Card } from "@/components/shadcn/card/card";
|
||||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||||
|
|
||||||
|
import { CategorySelector } from "./category-selector";
|
||||||
|
|
||||||
interface RiskRadarViewClientProps {
|
interface RiskRadarViewClientProps {
|
||||||
data: RadarDataPoint[];
|
data: RadarDataPoint[];
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,15 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
|||||||
setSelectedPoint(point);
|
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) => {
|
const handleBarClick = (dataPoint: BarDataPoint) => {
|
||||||
if (!selectedPoint) return;
|
if (!selectedPoint) return;
|
||||||
|
|
||||||
@@ -59,6 +70,11 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
|||||||
<h3 className="text-neutral-primary text-lg font-semibold">
|
<h3 className="text-neutral-primary text-lg font-semibold">
|
||||||
Risk Radar
|
Risk Radar
|
||||||
</h3>
|
</h3>
|
||||||
|
<CategorySelector
|
||||||
|
categories={data}
|
||||||
|
selectedCategory={selectedPoint?.categoryId ?? null}
|
||||||
|
onCategoryChange={handleCategoryChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative min-h-[400px] w-full flex-1">
|
<div className="relative min-h-[400px] w-full flex-1">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function ThreatScore({
|
|||||||
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
|
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Prowler Threat Score</CardTitle>
|
<CardTitle>Prowler ThreatScore</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
<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"
|
className="mt-0.5 min-h-4 min-w-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
Threat score has{" "}
|
Prowler ThreatScore has{" "}
|
||||||
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
|
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
|
||||||
{Math.abs(scoreDelta)}%
|
{Math.abs(scoreDelta)}%
|
||||||
</p>
|
</p>
|
||||||
@@ -194,7 +194,7 @@ export function ThreatScore({
|
|||||||
className="items-center justify-center"
|
className="items-center justify-center"
|
||||||
>
|
>
|
||||||
<p className="text-text-neutral-secondary text-sm">
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
Threat Score Data Unavailable
|
Prowler ThreatScore Data Unavailable
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ export default async function Findings({
|
|||||||
getScans({ pageSize: 50 }),
|
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 uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||||
const uniqueResourceTypes =
|
const uniqueResourceTypes =
|
||||||
metadataInfoData?.data?.attributes?.resource_types || [];
|
metadataInfoData?.data?.attributes?.resource_types || [];
|
||||||
|
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||||
|
|
||||||
// Extract provider IDs and details using helper functions
|
// Extract provider IDs and details using helper functions
|
||||||
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
||||||
@@ -93,6 +94,7 @@ export default async function Findings({
|
|||||||
uniqueRegions={uniqueRegions}
|
uniqueRegions={uniqueRegions}
|
||||||
uniqueServices={uniqueServices}
|
uniqueServices={uniqueServices}
|
||||||
uniqueResourceTypes={uniqueResourceTypes}
|
uniqueResourceTypes={uniqueResourceTypes}
|
||||||
|
uniqueCategories={uniqueCategories}
|
||||||
/>
|
/>
|
||||||
<Spacer y={8} />
|
<Spacer y={8} />
|
||||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [
|
|||||||
label: "Check ID",
|
label: "Check ID",
|
||||||
formatMultiple: (count) => `${count} Check IDs filtered`,
|
formatMultiple: (count) => `${count} Check IDs filtered`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
filterKey: "category__in",
|
|
||||||
label: "Category",
|
|
||||||
formatMultiple: (count) => `${count} Categories filtered`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
filterKey: "scan__in",
|
filterKey: "scan__in",
|
||||||
label: "Scan",
|
label: "Scan",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { filterFindings } from "@/components/filters/data-filters";
|
import { filterFindings } from "@/components/filters/data-filters";
|
||||||
import { FilterControls } from "@/components/filters/filter-controls";
|
import { FilterControls } from "@/components/filters/filter-controls";
|
||||||
import { useRelatedFilters } from "@/hooks";
|
import { useRelatedFilters } from "@/hooks";
|
||||||
|
import { getCategoryLabel } from "@/lib/categories";
|
||||||
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
|
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
|
||||||
|
|
||||||
interface FindingsFiltersProps {
|
interface FindingsFiltersProps {
|
||||||
@@ -14,6 +15,7 @@ interface FindingsFiltersProps {
|
|||||||
uniqueRegions: string[];
|
uniqueRegions: string[];
|
||||||
uniqueServices: string[];
|
uniqueServices: string[];
|
||||||
uniqueResourceTypes: string[];
|
uniqueResourceTypes: string[];
|
||||||
|
uniqueCategories: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FindingsFilters = ({
|
export const FindingsFilters = ({
|
||||||
@@ -24,6 +26,7 @@ export const FindingsFilters = ({
|
|||||||
uniqueRegions,
|
uniqueRegions,
|
||||||
uniqueServices,
|
uniqueServices,
|
||||||
uniqueResourceTypes,
|
uniqueResourceTypes,
|
||||||
|
uniqueCategories,
|
||||||
}: FindingsFiltersProps) => {
|
}: FindingsFiltersProps) => {
|
||||||
const { availableProviderIds, availableScans } = useRelatedFilters({
|
const { availableProviderIds, availableScans } = useRelatedFilters({
|
||||||
providerIds,
|
providerIds,
|
||||||
@@ -66,6 +69,13 @@ export const FindingsFilters = ({
|
|||||||
values: uniqueResourceTypes,
|
values: uniqueResourceTypes,
|
||||||
index: 8,
|
index: 8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: FilterType.CATEGORY,
|
||||||
|
labelCheckboxGroup: "Category",
|
||||||
|
values: uniqueCategories,
|
||||||
|
labelFormatter: getCategoryLabel,
|
||||||
|
index: 5,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.SCAN,
|
key: FilterType.SCAN,
|
||||||
labelCheckboxGroup: "Scan ID",
|
labelCheckboxGroup: "Scan ID",
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ export function HorizontalBarChart({
|
|||||||
"var(--bg-neutral-tertiary)";
|
"var(--bg-neutral-tertiary)";
|
||||||
|
|
||||||
const isClickable = !isEmpty && onBarClick;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -105,15 +116,13 @@ export function HorizontalBarChart({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bar - flexible */}
|
{/* 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" />
|
<div className="bg-bg-neutral-tertiary absolute inset-0 h-[22px] w-full rounded-sm" />
|
||||||
{(item.value > 0 || isEmpty) && (
|
{(item.value > 0 || isEmpty) && (
|
||||||
<div
|
<div
|
||||||
className="relative h-[22px] rounded-sm border border-black/10 transition-all duration-300"
|
className="relative h-[22px] rounded-sm border border-black/10 transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: isEmpty
|
width: `${calculatedWidth}%`,
|
||||||
? `${item.percentage}%`
|
|
||||||
: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
|
|
||||||
backgroundColor: barColor,
|
backgroundColor: barColor,
|
||||||
opacity: isFaded ? 0.5 : 1,
|
opacity: isFaded ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
@@ -174,7 +183,7 @@ export function HorizontalBarChart({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="min-w-[26px] text-right font-medium">
|
<span className="min-w-[26px] text-right font-medium">
|
||||||
{isEmpty ? "0" : item.percentage}%
|
{displayPercentage}%
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-medium">•</span>
|
<span className="shrink-0 font-medium">•</span>
|
||||||
<span className="font-bold whitespace-nowrap">
|
<span className="font-bold whitespace-nowrap">
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const CustomDot = ({
|
|||||||
}: CustomDotProps) => {
|
}: CustomDotProps) => {
|
||||||
const currentCategory = payload.name || payload.category;
|
const currentCategory = payload.name || payload.category;
|
||||||
const isSelected = selectedPoint?.category === currentCategory;
|
const isSelected = selectedPoint?.category === currentCategory;
|
||||||
|
const isFaded = selectedPoint !== null && !isSelected;
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -127,13 +128,14 @@ const CustomDot = ({
|
|||||||
cx={cx}
|
cx={cx}
|
||||||
cy={cy}
|
cy={cy}
|
||||||
r={isSelected ? 9 : 6}
|
r={isSelected ? 9 : 6}
|
||||||
fillOpacity={1}
|
|
||||||
style={{
|
style={{
|
||||||
fill: isSelected
|
fill: isSelected
|
||||||
? "var(--bg-button-primary)"
|
? "var(--bg-button-primary)"
|
||||||
: "var(--bg-radar-button)",
|
: "var(--bg-radar-button)",
|
||||||
|
fillOpacity: isFaded ? 0.3 : 1,
|
||||||
cursor: onSelectPoint ? "pointer" : "default",
|
cursor: onSelectPoint ? "pointer" : "default",
|
||||||
pointerEvents: "all",
|
pointerEvents: "all",
|
||||||
|
transition: "fill-opacity 200ms ease-in-out",
|
||||||
}}
|
}}
|
||||||
onClick={onSelectPoint ? handleClick : undefined}
|
onClick={onSelectPoint ? handleClick : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const SEVERITY_ORDER = {
|
|||||||
Medium: 2,
|
Medium: 2,
|
||||||
Low: 3,
|
Low: 3,
|
||||||
Informational: 4,
|
Informational: 4,
|
||||||
|
Info: 4,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const LAYOUT_OPTIONS = {
|
export const LAYOUT_OPTIONS = {
|
||||||
|
|||||||
@@ -151,13 +151,16 @@ export const DataTableFilterCustom = ({
|
|||||||
<MultiSelectSeparator />
|
<MultiSelectSeparator />
|
||||||
{filter.values.map((value) => {
|
{filter.values.map((value) => {
|
||||||
const entity = getEntityForValue(filter, value);
|
const entity = getEntityForValue(filter, value);
|
||||||
|
const displayLabel = filter.labelFormatter
|
||||||
|
? filter.labelFormatter(value)
|
||||||
|
: value;
|
||||||
return (
|
return (
|
||||||
<MultiSelectItem
|
<MultiSelectItem
|
||||||
key={value}
|
key={value}
|
||||||
value={value}
|
value={value}
|
||||||
badgeLabel={getBadgeLabel(entity, value)}
|
badgeLabel={getBadgeLabel(entity, displayLabel)}
|
||||||
>
|
>
|
||||||
{entity ? renderEntityContent(entity) : value}
|
{entity ? renderEntityContent(entity) : displayLabel}
|
||||||
</MultiSelectItem>
|
</MultiSelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
54
ui/lib/categories.ts
Normal file
54
ui/lib/categories.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface FilterOption {
|
|||||||
labelCheckboxGroup: string;
|
labelCheckboxGroup: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
|
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
|
||||||
|
labelFormatter?: (value: string) => string;
|
||||||
index?: number;
|
index?: number;
|
||||||
showSelectAll?: boolean;
|
showSelectAll?: boolean;
|
||||||
defaultToSelectAll?: boolean;
|
defaultToSelectAll?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user