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 API integration (#9532)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
3
ui/actions/overview/risk-radar/index.ts
Normal file
3
ui/actions/overview/risk-radar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./risk-radar";
|
||||
export * from "./risk-radar.adapter";
|
||||
export * from "./types";
|
||||
110
ui/actions/overview/risk-radar/risk-radar.adapter.ts
Normal file
110
ui/actions/overview/risk-radar/risk-radar.adapter.ts
Normal 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
|
||||
}
|
||||
34
ui/actions/overview/risk-radar/risk-radar.ts
Normal file
34
ui/actions/overview/risk-radar/risk-radar.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
1
ui/actions/overview/risk-radar/types/index.ts
Normal file
1
ui/actions/overview/risk-radar/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./risk-radar.types";
|
||||
32
ui/actions/overview/risk-radar/types/risk-radar.types.ts
Normal file
32
ui/actions/overview/risk-radar/types/risk-radar.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
126
ui/components/filters/active-filter-badge.tsx
Normal file
126
ui/components/filters/active-filter-badge.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface LineDataPoint {
|
||||
|
||||
export interface RadarDataPoint {
|
||||
category: string;
|
||||
categoryId: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
severityData?: BarDataPoint[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,4 +33,5 @@ export enum FilterType {
|
||||
SEVERITY = "severity__in",
|
||||
STATUS = "status__in",
|
||||
DELTA = "delta__in",
|
||||
CATEGORY = "category__in",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user