feat(ui): implement Risk Plot component with interactive legend and navigation (#9469)

This commit is contained in:
Alan Buscaglia
2025-12-05 14:03:58 +01:00
committed by GitHub
parent faaa172b86
commit 56bca7c104
12 changed files with 509 additions and 329 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Risk Plot component with interactive legend and severity navigation to Overview page [(#9469)](https://github.com/prowler-cloud/prowler/pull/9469)
- Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465)
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)

View File

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

View File

@@ -0,0 +1,4 @@
// Risk Plot Actions
export * from "./risk-plot";
export * from "./risk-plot.adapter";
export * from "./types/risk-plot.types";

View File

@@ -0,0 +1,94 @@
import { getProviderDisplayName } from "@/types/providers";
import type {
ProviderRiskData,
RiskPlotDataResponse,
RiskPlotPoint,
} from "./types/risk-plot.types";
/**
* Calculates percentage with proper rounding.
*/
function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
/**
* Adapts raw provider risk data to the format expected by RiskPlotClient.
*
* @param providersRiskData - Array of risk data per provider from API
* @returns Formatted data for the Risk Plot scatter chart
*/
export function adaptToRiskPlotData(
providersRiskData: ProviderRiskData[],
): RiskPlotDataResponse {
const points: RiskPlotPoint[] = [];
const providersWithoutData: RiskPlotDataResponse["providersWithoutData"] = [];
for (const providerData of providersRiskData) {
// Skip providers without ThreatScore data (no completed scans)
if (providerData.overallScore === null) {
providersWithoutData.push({
id: providerData.providerId,
name: providerData.providerName,
type: providerData.providerType,
});
continue;
}
// Convert provider type to display name (aws -> AWS, gcp -> Google, etc.)
const providerDisplayName = getProviderDisplayName(
providerData.providerType,
);
// Build severity data for the horizontal bar chart with percentages
let severityData;
let totalFailedFindings = 0;
if (providerData.severity) {
const { critical, high, medium, low, informational } =
providerData.severity;
totalFailedFindings = critical + high + medium + low + informational;
severityData = [
{
name: "Critical",
value: critical,
percentage: calculatePercentage(critical, totalFailedFindings),
},
{
name: "High",
value: high,
percentage: calculatePercentage(high, totalFailedFindings),
},
{
name: "Medium",
value: medium,
percentage: calculatePercentage(medium, totalFailedFindings),
},
{
name: "Low",
value: low,
percentage: calculatePercentage(low, totalFailedFindings),
},
{
name: "Info",
value: informational,
percentage: calculatePercentage(informational, totalFailedFindings),
},
];
}
points.push({
x: providerData.overallScore ?? 0,
y: totalFailedFindings,
provider: providerDisplayName,
name: providerData.providerName,
providerId: providerData.providerId,
severityData,
});
}
return { points, providersWithoutData };
}

View File

@@ -0,0 +1,69 @@
"use server";
import { getFindingsBySeverity } from "@/actions/overview/findings";
import { getThreatScore } from "@/actions/overview/threat-score";
import { ProviderProps } from "@/types/providers";
import { ProviderRiskData } from "./types/risk-plot.types";
/**
* Fetches risk data for a single provider.
* Combines ThreatScore and Severity data in parallel.
*/
export async function getProviderRiskData(
provider: ProviderProps,
): Promise<ProviderRiskData> {
const providerId = provider.id;
const providerType = provider.attributes.provider;
const providerName = provider.attributes.alias || provider.attributes.uid;
// Fetch ThreatScore and Severity in parallel
const [threatScoreResponse, severityResponse] = await Promise.all([
getThreatScore({
filters: {
provider_id: providerId,
include: "provider",
},
}),
getFindingsBySeverity({
filters: {
"filter[provider_id]": providerId,
"filter[status]": "FAIL",
},
}),
]);
// Extract ThreatScore data
// When filtering by single provider, API returns array with one item (not aggregated)
const threatScoreData = threatScoreResponse?.data?.[0]?.attributes;
const overallScore = threatScoreData?.overall_score
? parseFloat(threatScoreData.overall_score)
: null;
const failedFindings = threatScoreData?.failed_findings ?? 0;
// Extract Severity data
const severityData = severityResponse?.data?.attributes ?? null;
return {
providerId,
providerType,
providerName,
overallScore,
failedFindings,
severity: severityData,
};
}
/**
* Fetches risk data for multiple providers in parallel.
* Used by the Risk Plot SSR component.
*/
export async function getProvidersRiskData(
providers: ProviderProps[],
): Promise<ProviderRiskData[]> {
const riskDataPromises = providers.map((provider) =>
getProviderRiskData(provider),
);
return Promise.all(riskDataPromises);
}

View File

@@ -0,0 +1,58 @@
// Risk Plot Types
// Data structures for the Risk Plot scatter chart
import type { BarDataPoint } from "@/components/graphs/types";
/**
* Represents a single point in the Risk Plot scatter chart.
* Each point represents a provider/account with its risk metrics.
*/
export interface RiskPlotPoint {
/** ThreatScore (0-100 scale, higher = better) */
x: number;
/** Total failed findings count */
y: number;
/** Provider type display name (AWS, Azure, Google, etc.) */
provider: string;
/** Provider alias or UID (account identifier) */
name: string;
/** Provider ID for filtering/navigation */
providerId: string;
/** Severity breakdown for the horizontal bar chart */
severityData?: BarDataPoint[];
}
/**
* Raw data from the API combined for a single provider.
* Used internally before transformation to RiskPlotPoint.
*/
export interface ProviderRiskData {
providerId: string;
providerType: string;
providerName: string;
/** ThreatScore overall_score (0-100 scale) */
overallScore: number | null;
/** Failed findings from ThreatScore snapshot */
failedFindings: number;
/** Severity breakdown */
severity: {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
} | null;
}
/**
* Response structure for risk plot data fetching.
*/
export interface RiskPlotDataResponse {
points: RiskPlotPoint[];
/** Providers that have no data or no completed scans */
providersWithoutData: Array<{
id: string;
name: string;
type: string;
}>;
}

View File

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

View File

@@ -7,9 +7,9 @@ import { GraphsTabsClient } from "./_components/graphs-tabs-client";
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 { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
const LoadingFallback = () => (
@@ -25,9 +25,9 @@ const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
findings: FindingsViewSSR as 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-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {

View File

@@ -1,9 +1,19 @@
"use client";
/**
* Risk Plot Client Component
*
* NOTE: This component uses CSS variables (var()) for Recharts styling.
* Recharts SVG-based components (Scatter, XAxis, YAxis, CartesianGrid, etc.)
* do not support Tailwind classes and require raw color values or CSS variables.
* This is a documented limitation of the Recharts library.
* @see https://recharts.org/en-US/api
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
@@ -12,6 +22,7 @@ import {
YAxis,
} from "recharts";
import type { RiskPlotPoint } from "@/actions/overview/risk-plot";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { AlertPill } from "@/components/graphs/shared/alert-pill";
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
@@ -19,69 +30,83 @@ import {
AXIS_FONT_SIZE,
CustomXAxisTick,
} from "@/components/graphs/shared/custom-axis-tick";
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
import type { BarDataPoint } from "@/components/graphs/types";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
};
// 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
SUCCESS: "var(--bg-pass-primary)", // 61-100
} as const;
export interface ScatterPoint {
x: number;
y: number;
provider: string;
name: string;
severityData?: BarDataPoint[];
/**
* Get color based on ThreatScore (0-100 scale, higher = better)
*/
function getThreatScoreColor(score: number): string {
if (score > 60) return THREAT_COLORS.SUCCESS;
if (score > 30) return THREAT_COLORS.WARNING;
return THREAT_COLORS.DANGER;
}
// Provider colors from globals.css
const PROVIDER_COLORS: Record<string, string> = {
AWS: "var(--bg-data-aws)",
Azure: "var(--bg-data-azure)",
"Google Cloud": "var(--bg-data-gcp)",
Kubernetes: "var(--bg-data-kubernetes)",
"Microsoft 365": "var(--bg-data-m365)",
GitHub: "var(--bg-data-github)",
"MongoDB Atlas": "var(--bg-data-azure)",
"Infrastructure as Code": "var(--bg-data-kubernetes)",
"Oracle Cloud Infrastructure": "var(--bg-data-gcp)",
};
interface RiskPlotClientProps {
data: ScatterPoint[];
data: RiskPlotPoint[];
}
interface TooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterPoint }>;
payload?: Array<{ payload: RiskPlotPoint }>;
}
interface ScatterDotProps {
// Props that Recharts passes to the shape component
interface RechartsScatterDotProps {
cx: number;
cy: number;
payload: ScatterPoint;
selectedPoint: ScatterPoint | null;
onSelectPoint: (point: ScatterPoint) => void;
allData: ScatterPoint[];
payload: RiskPlotPoint;
}
interface LegendProps {
payload?: Array<{ value: string; color: string }>;
// Extended props for our custom scatter dot component
interface ScatterDotProps extends RechartsScatterDotProps {
selectedPoint: RiskPlotPoint | null;
onSelectPoint: (point: RiskPlotPoint) => void;
allData: RiskPlotPoint[];
selectedProvider: string | null;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
if (!active || !payload?.length) return null;
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{data.name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
<span style={{ color: severityColor, fontWeight: "bold" }}>
{data.x}
</span>{" "}
Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
const { name, x, y } = payload[0].payload;
const scoreColor = getThreatScoreColor(x);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
Threat Score
</p>
<div className="mt-2">
<AlertPill value={y} />
</div>
);
}
return null;
</div>
);
};
const CustomScatterDot = ({
@@ -91,24 +116,31 @@ const CustomScatterDot = ({
selectedPoint,
onSelectPoint,
allData,
selectedProvider,
}: ScatterDotProps) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const selectedColor = "var(--bg-button-primary)"; // emerald-400
const selectedColor = "var(--bg-button-primary)";
const fill = isSelected
? selectedColor
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
: PROVIDER_COLORS[payload.provider] || "var(--color-text-neutral-tertiary)";
const isFaded =
selectedProvider !== null && payload.provider !== selectedProvider;
const handleClick = () => {
const fullDataItem = allData?.find(
(d: ScatterPoint) => d.name === payload.name,
);
const fullDataItem = allData?.find((d) => d.name === payload.name);
onSelectPoint?.(fullDataItem || payload);
};
return (
<g style={{ cursor: "pointer" }} onClick={handleClick}>
<g
style={{
cursor: "pointer",
opacity: isFaded ? 0.2 : 1,
transition: "opacity 0.2s",
}}
onClick={handleClick}
>
{isSelected && (
<>
<circle
@@ -143,60 +175,86 @@ const CustomScatterDot = ({
);
};
const CustomLegend = ({ payload }: LegendProps) => {
const items =
payload?.map((entry: { value: string; color: string }) => ({
label: entry.value,
color: entry.color,
})) || [];
return <ChartLegend items={items} />;
};
/**
* Factory function that creates a scatter dot shape component with closure over selection state.
* Recharts shape prop types the callback parameter as `unknown` due to its flexible API.
* We safely cast to RechartsScatterDotProps since we know the actual shape of props passed by Scatter.
* @see https://recharts.org/en-US/api/Scatter#shape
*/
function createScatterDotShape(
selectedPoint: ScatterPoint | null,
onSelectPoint: (point: ScatterPoint) => void,
allData: ScatterPoint[],
) {
const ScatterDotShape = (props: unknown) => {
const dotProps = props as Omit<
ScatterDotProps,
"selectedPoint" | "onSelectPoint" | "allData"
>;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
/>
);
};
selectedPoint: RiskPlotPoint | null,
onSelectPoint: (point: RiskPlotPoint) => void,
allData: RiskPlotPoint[],
selectedProvider: string | null,
): (props: unknown) => React.JSX.Element {
const ScatterDotShape = (props: unknown) => (
<CustomScatterDot
{...(props as RechartsScatterDotProps)}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
selectedProvider={selectedProvider}
/>
);
ScatterDotShape.displayName = "ScatterDotShape";
return ScatterDotShape;
}
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
null,
);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const dataByProvider = data.reduce(
// Group data by provider for separate Scatter series
const dataByProvider = data.reduce<Record<string, RiskPlotPoint[]>>(
(acc, point) => {
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
(acc[point.provider] ??= []).push(point);
return acc;
},
{} as Record<string, typeof data>,
{},
);
const handleSelectPoint = (point: ScatterPoint) => {
if (selectedPoint?.name === point.name) {
setSelectedPoint(null);
} else {
setSelectedPoint(point);
const providers = Object.keys(dataByProvider);
const handleSelectPoint = (point: RiskPlotPoint) => {
setSelectedPoint((current) =>
current?.name === point.name ? null : point,
);
};
const handleProviderClick = (provider: string) => {
setSelectedProvider((current) => (current === provider ? null : provider));
};
const handleBarClick = (dataPoint: BarDataPoint) => {
if (!selectedPoint) return;
// Build the URL with current filters
const params = new URLSearchParams(searchParams.toString());
// Transform provider filters (provider_id__in -> provider__in)
mapProviderFiltersForFindings(params);
// Add severity filter
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
if (severity) {
params.set("filter[severity__in]", severity);
}
// Add provider filter for the selected point
params.set("filter[provider__in]", selectedPoint.providerId);
// 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 (
@@ -204,26 +262,14 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
<div className="flex flex-1 gap-12">
{/* Plot Section - in Card */}
<div className="flex basis-[70%] flex-col">
<div
className="flex flex-1 flex-col rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex flex-1 flex-col rounded-lg border p-4">
<div className="mb-4">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
<h3 className="text-text-neutral-primary text-lg font-semibold">
Risk Plot
</h3>
</div>
<div
className="relative w-full flex-1"
style={{ minHeight: "400px" }}
>
<div className="relative min-h-[400px] w-full flex-1">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
@@ -237,16 +283,16 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
<XAxis
type="number"
dataKey="x"
name="Risk Score"
name="Threat Score"
label={{
value: "Risk Score",
value: "Threat Score",
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={CustomXAxisTick}
tickLine={false}
domain={[0, 10]}
domain={[0, 100]}
axisLine={false}
/>
<YAxis
@@ -268,30 +314,43 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
content={<CustomLegend />}
wrapperStyle={{ paddingTop: "40px" }}
/>
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[
provider as keyof typeof PROVIDER_COLORS
] || "var(--color-text-neutral-tertiary)"
PROVIDER_COLORS[provider] ||
"var(--color-text-neutral-tertiary)"
}
shape={createScatterDotShape(
selectedPoint,
handleSelectPoint,
data,
selectedProvider,
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Interactive Legend - below chart */}
<div className="mt-4 flex flex-col items-start gap-2">
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by provider.
</p>
<ChartLegend
items={providers.map((p) => ({
label: p,
color:
PROVIDER_COLORS[p] || "var(--color-text-neutral-tertiary)",
dataKey: p,
}))}
selectedItem={selectedProvider}
onItemClick={handleProviderClick}
/>
</div>
</div>
</div>
@@ -300,28 +359,22 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4
className="text-base font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
<h4 className="text-text-neutral-primary text-base font-semibold">
{selectedPoint.name}
</h4>
<p
className="text-xs"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
<p className="text-text-neutral-tertiary text-xs">
Threat Score: {selectedPoint.x}% | Failed Findings:{" "}
{selectedPoint.y}
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={handleBarClick}
/>
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
<p className="text-text-neutral-tertiary text-sm">
Select a point on the plot to view details
</p>
</div>

View File

@@ -1,191 +0,0 @@
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
// Mock data - Risk Score (0-10) vs Failed Findings count
const mockScatterData: ScatterPoint[] = [
{
x: 9.2,
y: 1456,
provider: "AWS",
name: "Amazon RDS",
severityData: [
{ name: "Critical", value: 456 },
{ name: "High", value: 600 },
{ name: "Medium", value: 250 },
{ name: "Low", value: 120 },
{ name: "Info", value: 30 },
],
},
{
x: 8.5,
y: 892,
provider: "AWS",
name: "Amazon EC2",
severityData: [
{ name: "Critical", value: 280 },
{ name: "High", value: 350 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 70 },
{ name: "Info", value: 12 },
],
},
{
x: 7.1,
y: 445,
provider: "AWS",
name: "Amazon S3",
severityData: [
{ name: "Critical", value: 140 },
{ name: "High", value: 180 },
{ name: "Medium", value: 90 },
{ name: "Low", value: 30 },
{ name: "Info", value: 5 },
],
},
{
x: 6.3,
y: 678,
provider: "AWS",
name: "AWS Lambda",
severityData: [
{ name: "Critical", value: 214 },
{ name: "High", value: 270 },
{ name: "Medium", value: 135 },
{ name: "Low", value: 54 },
{ name: "Info", value: 5 },
],
},
{
x: 4.2,
y: 156,
provider: "AWS",
name: "AWS Backup",
severityData: [
{ name: "Critical", value: 49 },
{ name: "High", value: 62 },
{ name: "Medium", value: 31 },
{ name: "Low", value: 12 },
{ name: "Info", value: 2 },
],
},
{
x: 8.8,
y: 1023,
provider: "Azure",
name: "Azure SQL Database",
severityData: [
{ name: "Critical", value: 323 },
{ name: "High", value: 410 },
{ name: "Medium", value: 205 },
{ name: "Low", value: 82 },
{ name: "Info", value: 3 },
],
},
{
x: 7.9,
y: 834,
provider: "Azure",
name: "Azure Virtual Machines",
severityData: [
{ name: "Critical", value: 263 },
{ name: "High", value: 334 },
{ name: "Medium", value: 167 },
{ name: "Low", value: 67 },
{ name: "Info", value: 3 },
],
},
{
x: 6.4,
y: 567,
provider: "Azure",
name: "Azure Storage",
severityData: [
{ name: "Critical", value: 179 },
{ name: "High", value: 227 },
{ name: "Medium", value: 113 },
{ name: "Low", value: 45 },
{ name: "Info", value: 3 },
],
},
{
x: 5.1,
y: 289,
provider: "Azure",
name: "Azure Key Vault",
severityData: [
{ name: "Critical", value: 91 },
{ name: "High", value: 115 },
{ name: "Medium", value: 58 },
{ name: "Low", value: 23 },
{ name: "Info", value: 2 },
],
},
{
x: 7.6,
y: 712,
provider: "Google",
name: "Cloud SQL",
severityData: [
{ name: "Critical", value: 225 },
{ name: "High", value: 285 },
{ name: "Medium", value: 142 },
{ name: "Low", value: 57 },
{ name: "Info", value: 3 },
],
},
{
x: 6.9,
y: 623,
provider: "Google",
name: "Compute Engine",
severityData: [
{ name: "Critical", value: 197 },
{ name: "High", value: 249 },
{ name: "Medium", value: 124 },
{ name: "Low", value: 50 },
{ name: "Info", value: 3 },
],
},
{
x: 5.8,
y: 412,
provider: "Google",
name: "Cloud Storage",
severityData: [
{ name: "Critical", value: 130 },
{ name: "High", value: 165 },
{ name: "Medium", value: 82 },
{ name: "Low", value: 33 },
{ name: "Info", value: 2 },
],
},
{
x: 4.5,
y: 198,
provider: "Google",
name: "Cloud Run",
severityData: [
{ name: "Critical", value: 63 },
{ name: "High", value: 79 },
{ name: "Medium", value: 39 },
{ name: "Low", value: 16 },
{ name: "Info", value: 1 },
],
},
{
x: 8.9,
y: 945,
provider: "AWS",
name: "Amazon RDS Aurora",
severityData: [
{ name: "Critical", value: 299 },
{ name: "High", value: 378 },
{ name: "Medium", value: 189 },
{ name: "Low", value: 76 },
{ name: "Info", value: 3 },
],
},
];
export function RiskPlotView() {
return <RiskPlotClient data={mockScatterData} />;
}

View File

@@ -0,0 +1,91 @@
import { Info } from "lucide-react";
import {
adaptToRiskPlotData,
getProvidersRiskData,
} from "@/actions/overview/risk-plot";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getProviders({ pageSize: 200 });
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
let filteredProviders = allProviders;
if (providerIdFilter) {
// Filter by specific provider IDs
const selectedIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
} else if (providerTypeFilter) {
// Filter by provider types
const selectedTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
filteredProviders = allProviders.filter((p) =>
selectedTypes.includes(p.attributes.provider.toLowerCase()),
);
}
// No providers to show
if (filteredProviders.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 providers available for the selected filters
</p>
</div>
</div>
);
}
// Fetch risk data for all filtered providers in parallel
const providersRiskData = await getProvidersRiskData(filteredProviders);
// Transform to chart format
const { points, providersWithoutData } =
adaptToRiskPlotData(providersRiskData);
// No data available
if (points.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 risk data available for the selected providers
</p>
{providersWithoutData.length > 0 && (
<p className="text-text-neutral-tertiary text-xs">
{providersWithoutData.length} provider(s) have no completed scans
</p>
)}
</div>
</div>
);
}
return (
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient data={points} />
</div>
);
}

View File

@@ -32,11 +32,11 @@ export function AlertPill({
>
<AlertTriangle
size={iconSize}
style={{ color: "var(--color-text-text-error)" }}
style={{ color: "var(--color-text-error-primary)" }}
/>
<span
className={cn(textSizeClass, "font-semibold")}
style={{ color: "var(--color-text-text-error)" }}
style={{ color: "var(--color-text-error-primary)" }}
>
{value}
</span>