feat(ui): add Risk Radar component with API integration (#9532)

This commit is contained in:
Alan Buscaglia
2025-12-15 17:02:21 +01:00
committed by GitHub
parent 3eb278cb9f
commit 8f361e7e8d
71 changed files with 431 additions and 309 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Risk Radar component with category-based severity breakdown to Overview page [(#9532)](https://github.com/prowler-cloud/prowler/pull/9532)
- More extensive resource details (partition, details and metadata) within Findings detail and Resources detail view [(#9515)](https://github.com/prowler-cloud/prowler/pull/9515)
### 🔄 Changed
@@ -21,6 +22,8 @@ All notable changes to the **Prowler UI** are documented in this file.
- Bump Next.js to version 15.5.9 [(#9522)](https://github.com/prowler-cloud/prowler/pull/9522), [(#9513)](https://github.com/prowler-cloud/prowler/pull/9513)
- Bump React to version 19.2.2 [(#9534)](https://github.com/prowler-cloud/prowler/pull/9534)
---
## [1.15.0] (Prowler v5.15.0)
### 🚀 Added

View File

@@ -15,7 +15,6 @@ export interface AttackSurfaceItem {
label: string;
failedFindings: number;
totalFindings: number;
checkIds: string[];
}
const ATTACK_SURFACE_LABELS: Record<AttackSurfaceId, string> = {
@@ -39,7 +38,6 @@ function mapAttackSurfaceItem(item: AttackSurfaceOverview): AttackSurfaceItem {
label: ATTACK_SURFACE_LABELS[id] || item.id,
failedFindings: item.attributes.failed_findings,
totalFindings: item.attributes.total_findings,
checkIds: item.attributes.check_ids ?? [],
};
}

View File

@@ -4,6 +4,7 @@ export * from "./findings";
export * from "./providers";
export * from "./regions";
export * from "./risk-plot";
export * from "./risk-radar";
export * from "./services";
export * from "./severity-trends";
export * from "./threat-score";

View File

@@ -0,0 +1,3 @@
export * from "./risk-radar";
export * from "./risk-radar.adapter";
export * from "./types";

View File

@@ -0,0 +1,110 @@
import type { RadarDataPoint } from "@/components/graphs/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.
*/
function calculateChangePercentage(
newFailedFindings: number,
failedFindings: number,
): number {
if (failedFindings === 0) return 0;
return Math.round((newFailedFindings / failedFindings) * 100);
}
/**
* Maps a single category overview item to a RadarDataPoint.
*/
function mapCategoryToRadarPoint(item: CategoryOverview): RadarDataPoint {
const { id, attributes } = item;
const { failed_findings, new_failed_findings, severity } = attributes;
return {
category: getCategoryLabel(id),
categoryId: id,
value: failed_findings,
change: calculateChangePercentage(new_failed_findings, failed_findings),
severityData: [
{ name: "Critical", value: severity.critical },
{ name: "High", value: severity.high },
{ name: "Medium", value: severity.medium },
{ name: "Low", value: severity.low },
{ name: "Info", value: severity.informational },
],
};
}
/**
* Adapts the category overview API response to RadarDataPoint[] format.
* Filters out categories with no failed findings.
*
* @param response - The category overview API response
* @returns An array of RadarDataPoint objects for the radar chart
*/
export function adaptCategoryOverviewToRadarData(
response: CategoryOverviewResponse | undefined,
): RadarDataPoint[] {
if (!response?.data || response.data.length === 0) {
return [];
}
// Map all categories to radar points, filtering out those with no failed findings
return response.data
.filter((item) => item.attributes.failed_findings > 0)
.map(mapCategoryToRadarPoint)
.sort((a, b) => b.value - a.value); // Sort by failed findings descending
}

View File

@@ -0,0 +1,34 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { CategoryOverviewResponse } from "./types";
export const getCategoryOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<CategoryOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/categories`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching category overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1 @@
export * from "./risk-radar.types";

View File

@@ -0,0 +1,32 @@
// Category Overview Types
// Corresponds to the /overviews/categories endpoint
interface OverviewResponseMeta {
version: string;
}
export interface CategorySeverity {
informational: number;
low: number;
medium: number;
high: number;
critical: number;
}
export interface CategoryOverviewAttributes {
total_findings: number;
failed_findings: number;
new_failed_findings: number;
severity: CategorySeverity;
}
export interface CategoryOverview {
type: "category-overviews";
id: string;
attributes: CategoryOverviewAttributes;
}
export interface CategoryOverviewResponse {
data: CategoryOverview[];
meta: OverviewResponseMeta;
}

View File

@@ -3,7 +3,7 @@
import {
getDateFromForTimeRange,
type TimeRange,
} from "@/app/(prowler)/_new-overview/severity-over-time/_constants/time-range.constants";
} from "@/app/(prowler)/_overview/severity-over-time/_constants/time-range.constants";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";

View File

@@ -1,89 +0,0 @@
import type { RadarDataPoint } from "@/components/graphs/types";
import { RiskRadarViewClient } from "./risk-radar-view-client";
// Mock data - replace with actual API call
const mockRadarData: RadarDataPoint[] = [
{
category: "Amazon Kinesis",
value: 45,
change: 2,
severityData: [
{ name: "Critical", value: 32 },
{ name: "High", value: 65 },
{ name: "Medium", value: 18 },
{ name: "Low", value: 54 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon MQ",
value: 38,
change: -1,
severityData: [
{ name: "Critical", value: 28 },
{ name: "High", value: 58 },
{ name: "Medium", value: 16 },
{ name: "Low", value: 48 },
{ name: "Info", value: 2 },
],
},
{
category: "AWS Lambda",
value: 52,
change: 5,
severityData: [
{ name: "Critical", value: 40 },
{ name: "High", value: 72 },
{ name: "Medium", value: 20 },
{ name: "Low", value: 60 },
{ name: "Info", value: 3 },
],
},
{
category: "Amazon RDS",
value: 41,
change: 3,
severityData: [
{ name: "Critical", value: 30 },
{ name: "High", value: 60 },
{ name: "Medium", value: 17 },
{ name: "Low", value: 50 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon S3",
value: 48,
change: -2,
severityData: [
{ name: "Critical", value: 36 },
{ name: "High", value: 68 },
{ name: "Medium", value: 19 },
{ name: "Low", value: 56 },
{ name: "Info", value: 2 },
],
},
{
category: "Amazon VPC",
value: 55,
change: 4,
severityData: [
{ name: "Critical", value: 42 },
{ name: "High", value: 75 },
{ name: "Medium", value: 21 },
{ name: "Low", value: 62 },
{ name: "Info", value: 3 },
],
},
];
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function RiskRadarViewSSR() {
// TODO: Call server action to fetch radar chart data
await delay(3000); // Simulating server action fetch time
return <RiskRadarViewClient data={mockRadarData} />;
}

View File

@@ -1,74 +0,0 @@
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./_components/accounts-selector";
import { ProviderTypeSelector } from "./_components/provider-type-selector";
import { CheckFindingsSSR } from "./check-findings";
import { GraphsTabsWrapper } from "./graphs-tabs/graphs-tabs-wrapper";
import { RiskSeverityChartSkeleton } from "./risk-severity";
import { RiskSeverityChartSSR } from "./risk-severity/risk-severity-chart.ssr";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./severity-over-time/finding-severity-over-time.ssr";
import { StatusChartSkeleton } from "./status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./watchlist";
export default async function NewOverviewPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
//if cloud env throw a 500 err
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
throw new Error("500");
}
const resolvedSearchParams = await searchParams;
const providersData = await getProviders({ page: 1, pageSize: 200 });
return (
<ContentLayout title="New Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex gap-6">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex gap-6">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</div>
</ContentLayout>
);
}

View File

@@ -1,11 +0,0 @@
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
export const RiskSeverityChartSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
return <RiskSeverityChartDetailSSR searchParams={filters} />;
};

View File

@@ -1,35 +0,0 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
const EmptyState = ({ message }: { message: string }) => (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">{message}</p>
</div>
);
export const FindingSeverityOverTimeDetailSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const result = await getFindingsSeverityTrends({ filters });
if (result.status === "error") {
return <EmptyState message="Failed to load severity trends data" />;
}
if (result.status === "empty") {
return <EmptyState message="No severity trends data available" />;
}
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary overflow-visible rounded-lg border p-4">
<h3 className="text-text-neutral-primary mb-4 text-lg font-semibold">
Finding Severity Over Time
</h3>
<FindingSeverityOverTime data={result.data.data} />
</div>
);
};

View File

@@ -13,16 +13,12 @@ export function AttackSurfaceCardItem({
item,
filters = {},
}: AttackSurfaceCardItemProps) {
const hasCheckIds = item.checkIds.length > 0;
// Build URL with current filters + attack surface specific filters
const buildFindingsUrl = () => {
if (!hasCheckIds) return null;
const params = new URLSearchParams();
// Add attack surface specific filters
params.set("filter[check_id__in]", item.checkIds.join(","));
// Add attack surface category filter
params.set("filter[category__in]", item.id);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
@@ -44,11 +40,8 @@ export function AttackSurfaceCardItem({
const hasFindings = item.failedFindings > 0;
const getCardStyles = () => {
if (!hasCheckIds) {
return "opacity-50 cursor-not-allowed";
}
if (hasFindings) {
return "cursor-pointer border-rose-500/40 shadow-[0_0_12px_rgba(244,63,94,0.2)] transition-all hover:border-rose-500/60 hover:shadow-[0_0_16px_rgba(244,63,94,0.3)]";
return "cursor-pointer border-rose-500/40 shadow-rose-500/20 shadow-lg transition-all hover:border-rose-500/60 hover:shadow-rose-500/30";
}
return "cursor-pointer transition-colors hover:bg-accent";
};
@@ -74,13 +67,9 @@ export function AttackSurfaceCardItem({
</Card>
);
if (findingsUrl) {
return (
<Link href={findingsUrl} className="flex min-w-[200px] flex-1">
{cardContent}
</Link>
);
}
return cardContent;
return (
<Link href={findingsUrl} className="flex min-w-[200px] flex-1">
{cardContent}
</Link>
);
}

View File

@@ -3,23 +3,22 @@ export const GRAPH_TABS = [
id: "findings",
label: "New Findings",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-radar",
label: "Risk Radar",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "risk-plot",
label: "Risk Plot",
},
// TODO: Uncomment when ready to enable other tabs
// {
// id: "risk-radar",
// label: "Risk Radar",
// },
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];

View File

@@ -8,9 +8,8 @@ import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
import { FindingsViewSSR } from "./findings-view";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotSSR } from "./risk-plot/risk-plot.ssr";
import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
const LoadingFallback = () => (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex w-full flex-col space-y-4 rounded-lg border p-4">
@@ -26,8 +25,7 @@ const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-plot": RiskPlotSSR as GraphComponent,
// TODO: Uncomment when ready to enable other tabs
// "risk-radar": RiskRadarViewSSR as GraphComponent,
"risk-radar": RiskRadarViewSSR as GraphComponent,
};
interface GraphsTabsWrapperProps {

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";
// ThreatScore colors (0-100 scale, higher = better)
// Threat Score 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>{" "}
Prowler ThreatScore
Threat Score
</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">
Prowler ThreatScore is severity-weighted, not quantity-based.
Higher severity findings have greater impact on the score.
Threat Score 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="Prowler ThreatScore"
name="Threat Score"
label={{
value: "Prowler ThreatScore",
value: "Threat Score",
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">
Prowler ThreatScore: {selectedPoint.x}% | Fail Findings:{" "}
Threat Score: {selectedPoint.x}% | Fail Findings:{" "}
{selectedPoint.y}
</p>
</div>

View File

@@ -1,17 +1,21 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { RadarChart } from "@/components/graphs/radar-chart";
import type { RadarDataPoint } from "@/components/graphs/types";
import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types";
import { Card } from "@/components/shadcn/card/card";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
interface RiskRadarViewClientProps {
data: RadarDataPoint[];
}
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
null,
);
@@ -20,6 +24,31 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
setSelectedPoint(point);
};
const handleBarClick = (dataPoint: BarDataPoint) => {
if (!selectedPoint) return;
// Build the URL with current filters
const params = new URLSearchParams(searchParams.toString());
// Add severity filter
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
if (severity) {
params.set("filter[severity__in]", severity);
}
// Add category filter for the selected point
params.set("filter[category__in]", selectedPoint.categoryId);
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Filter by FAIL findings
params.set("filter[status__in]", "FAIL");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
@@ -55,7 +84,10 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
{selectedPoint.value} Total Findings
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={handleBarClick}
/>
</div>
) : (
<div className="flex w-full items-center justify-center text-center">

View File

@@ -0,0 +1,40 @@
import { Info } from "lucide-react";
import {
adaptCategoryOverviewToRadarData,
getCategoryOverview,
} from "@/actions/overview/risk-radar";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import { RiskRadarViewClient } from "./risk-radar-view-client";
export async function RiskRadarViewSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
// Fetch category overview data
const categoryResponse = await getCategoryOverview({ filters });
// Transform to radar chart format
const radarData = adaptCategoryOverviewToRadarData(categoryResponse);
// No data available
if (radarData.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<Info size={48} className="text-text-neutral-tertiary" />
<p className="text-text-neutral-secondary text-sm">
No category data available for the selected filters
</p>
</div>
</div>
);
}
return <RiskRadarViewClient data={radarData} />;
}

View File

@@ -4,7 +4,7 @@ import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChart } from "./_components/risk-severity-chart";
export const RiskSeverityChartDetailSSR = async ({
export const RiskSeverityChartSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);

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 ThreatScore</CardTitle>
<CardTitle>Prowler Threat Score</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>
Prowler ThreatScore has{" "}
Threat score 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">
Prowler ThreatScore Data Unavailable
Threat Score Data Unavailable
</p>
</Card>
)}

View File

@@ -4,32 +4,29 @@ import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./_new-overview/_components/accounts-selector";
import { ProviderTypeSelector } from "./_new-overview/_components/provider-type-selector";
import { AccountsSelector } from "./_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
} from "./_new-overview/attack-surface";
import { CheckFindingsSSR } from "./_new-overview/check-findings";
import { GraphsTabsWrapper } from "./_new-overview/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_new-overview/graphs-tabs/risk-pipeline-view";
} from "./_overview/attack-surface";
import { CheckFindingsSSR } from "./_overview/check-findings";
import { GraphsTabsWrapper } from "./_overview/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_overview/graphs-tabs/risk-pipeline-view";
import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./_new-overview/risk-severity";
} from "./_overview/risk-severity";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./_new-overview/severity-over-time/finding-severity-over-time.ssr";
import { StatusChartSkeleton } from "./_new-overview/status-chart";
import {
ThreatScoreSkeleton,
ThreatScoreSSR,
} from "./_new-overview/threat-score";
} from "./_overview/severity-over-time/finding-severity-over-time.ssr";
import { StatusChartSkeleton } from "./_overview/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score";
import {
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_new-overview/watchlist";
} from "./_overview/watchlist";
export default async function Home({
searchParams,

View File

@@ -1,35 +0,0 @@
"use client";
import { X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Badge } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
export const ActiveCheckIdFilter = () => {
const searchParams = useSearchParams();
const { clearFilter } = useUrlFilters();
const checkIdFilter = searchParams.get("filter[check_id__in]");
if (!checkIdFilter) {
return null;
}
const checkIds = checkIdFilter.split(",");
const displayText =
checkIds.length > 1
? `${checkIds.length} Check IDs filtered`
: `Check ID: ${checkIds[0]}`;
return (
<Badge
variant="outline"
className="flex cursor-pointer items-center gap-1 px-3 py-1.5"
onClick={() => clearFilter("check_id__in")}
>
<span className="max-w-[200px] truncate text-sm">{displayText}</span>
<X className="size-3.5 shrink-0" />
</Badge>
);
};

View File

@@ -0,0 +1,126 @@
"use client";
import { X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Badge } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
export interface FilterBadgeConfig {
/**
* The filter key without the "filter[]" wrapper.
* Example: "scan__in", "check_id__in", "provider__in"
*/
filterKey: string;
/**
* Label to display before the value.
* Example: "Scan", "Check ID", "Provider"
*/
label: string;
/**
* Optional function to format a single value for display.
* Useful for truncating UUIDs, etc.
* Default: shows value as-is
*/
formatValue?: (value: string) => string;
/**
* Optional function to format the display when multiple values are selected.
* Default: "{count} {label}s filtered"
*/
formatMultiple?: (count: number, label: string) => string;
}
/**
* Default filter badge configurations for common use cases.
* Add new filters here to automatically show them as badges.
*/
export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [
{
filterKey: "check_id__in",
label: "Check ID",
formatMultiple: (count) => `${count} Check IDs filtered`,
},
{
filterKey: "category__in",
label: "Category",
formatMultiple: (count) => `${count} Categories filtered`,
},
{
filterKey: "scan__in",
label: "Scan",
formatValue: (id) => `${id.slice(0, 8)}...`,
},
];
interface ActiveFilterBadgeProps {
config: FilterBadgeConfig;
}
/**
* Single filter badge component that reads from URL and displays if active.
*/
const ActiveFilterBadge = ({ config }: ActiveFilterBadgeProps) => {
const searchParams = useSearchParams();
const { clearFilter } = useUrlFilters();
const {
filterKey,
label,
formatValue = (v) => v,
formatMultiple = (count, lbl) => `${count} ${lbl}s filtered`,
} = config;
const fullKey = filterKey.startsWith("filter[")
? filterKey
: `filter[${filterKey}]`;
const filterValue = searchParams.get(fullKey);
if (!filterValue) {
return null;
}
const values = filterValue.split(",");
const displayText =
values.length > 1
? formatMultiple(values.length, label)
: `${label}: ${formatValue(values[0])}`;
return (
<Badge
variant="outline"
className="flex cursor-pointer items-center gap-1 px-3 py-1.5"
onClick={() => clearFilter(filterKey)}
>
<span className="max-w-[200px] truncate text-sm">{displayText}</span>
<X className="size-3.5 shrink-0" />
</Badge>
);
};
interface ActiveFilterBadgesProps {
/**
* Filter configurations to render as badges.
* Defaults to DEFAULT_FILTER_BADGES if not provided.
*/
filters?: FilterBadgeConfig[];
}
/**
* Renders filter badges for all configured filters that are active in the URL.
* Only shows badges for filters that have values in the URL params.
*/
export const ActiveFilterBadges = ({
filters = DEFAULT_FILTER_BADGES,
}: ActiveFilterBadgesProps) => {
return (
<>
{filters.map((config) => (
<ActiveFilterBadge key={config.filterKey} config={config} />
))}
</>
);
};

View File

@@ -1,4 +1,4 @@
export * from "./active-check-id-filter";
export * from "./active-filter-badge";
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";

View File

@@ -112,6 +112,7 @@ const CustomDot = ({
);
const point: RadarDataPoint = {
category: currentCategory,
categoryId: fullDataItem?.categoryId || payload.categoryId || "",
value: payload.value,
change: payload.change,
severityData: fullDataItem?.severityData || payload.severityData,

View File

@@ -32,6 +32,7 @@ export interface LineDataPoint {
export interface RadarDataPoint {
category: string;
categoryId: string;
value: number;
change?: number;
severityData?: BarDataPoint[];

View File

@@ -3,7 +3,7 @@
import { useSearchParams } from "next/navigation";
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
import { ActiveCheckIdFilter } from "@/components/filters/active-check-id-filter";
import { ActiveFilterBadges } from "@/components/filters/active-filter-badge";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
MultiSelect,
@@ -166,7 +166,7 @@ export const DataTableFilterCustom = ({
);
})}
<div className="flex items-center justify-start gap-2">
<ActiveCheckIdFilter />
<ActiveFilterBadges />
<ClearFiltersButton />
</div>
</div>

View File

@@ -33,4 +33,5 @@ export enum FilterType {
SEVERITY = "severity__in",
STATUS = "status__in",
DELTA = "delta__in",
CATEGORY = "category__in",
}