Skip to content

Commit 4e9dd46

Browse files
feat(ui): add Risk Pipeline View with Sankey chart to Overview page (#9320)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
1 parent 880345b commit 4e9dd46

File tree

17 files changed

+610
-222
lines changed

17 files changed

+610
-222
lines changed

ui/CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to the **Prowler UI** are documented in this file.
44

5+
## [1.15.0] (Unreleased)
6+
7+
### 🚀 Added
8+
9+
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
10+
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
11+
- Risk Pipeline component with Sankey chart to Overview page [(#9317)](https://github.com/prowler-cloud/prowler/pull/9317)
12+
513
## [1.14.0] (Prowler v5.14.0)
614

715
### 🚀 Added
@@ -15,8 +23,6 @@ All notable changes to the **Prowler UI** are documented in this file.
1523
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
1624
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
1725
- Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296)
18-
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
19-
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
2026

2127
### 🔄 Changed
2228

@@ -27,7 +33,7 @@ All notable changes to the **Prowler UI** are documented in this file.
2733

2834
---
2935

30-
## [1.13.1]
36+
## [1.13.1] (Prolwer v5.13.1)
3137

3238
### 🔄 Changed
3339

ui/actions/overview/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./overview";
2+
export * from "./overview.adapter";
23
export * from "./types";
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import {
2+
FindingsSeverityOverviewResponse,
3+
ProviderOverview,
4+
ProvidersOverviewResponse,
5+
} from "./types";
6+
7+
/**
8+
* Sankey chart node structure
9+
*/
10+
export interface SankeyNode {
11+
name: string;
12+
}
13+
14+
/**
15+
* Sankey chart link structure
16+
*/
17+
export interface SankeyLink {
18+
source: number;
19+
target: number;
20+
value: number;
21+
}
22+
23+
/**
24+
* Sankey chart data structure
25+
*/
26+
export interface SankeyData {
27+
nodes: SankeyNode[];
28+
links: SankeyLink[];
29+
}
30+
31+
/**
32+
* Provider display name mapping
33+
* Maps provider IDs to user-friendly display names
34+
* These names must match the COLOR_MAP keys in sankey-chart.tsx
35+
*/
36+
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
37+
aws: "AWS",
38+
azure: "Azure",
39+
gcp: "Google Cloud",
40+
kubernetes: "Kubernetes",
41+
github: "GitHub",
42+
m365: "Microsoft 365",
43+
iac: "Infrastructure as Code",
44+
oraclecloud: "Oracle Cloud Infrastructure",
45+
};
46+
47+
/**
48+
* Aggregated provider data after grouping by provider type
49+
*/
50+
interface AggregatedProvider {
51+
id: string;
52+
displayName: string;
53+
pass: number;
54+
fail: number;
55+
}
56+
57+
/**
58+
* Provider types to exclude from the Sankey chart
59+
*/
60+
const EXCLUDED_PROVIDERS = new Set(["mongo", "mongodb", "mongodbatlas"]);
61+
62+
/**
63+
* Aggregates multiple provider entries by provider type (id)
64+
* Since the API can return multiple entries for the same provider type,
65+
* we need to sum up their findings
66+
*
67+
* @param providers - Raw provider overview data from API
68+
* @returns Aggregated providers with summed findings
69+
*/
70+
function aggregateProvidersByType(
71+
providers: ProviderOverview[],
72+
): AggregatedProvider[] {
73+
const aggregated = new Map<string, AggregatedProvider>();
74+
75+
for (const provider of providers) {
76+
const { id, attributes } = provider;
77+
78+
// Skip excluded providers
79+
if (EXCLUDED_PROVIDERS.has(id)) {
80+
continue;
81+
}
82+
83+
const existing = aggregated.get(id);
84+
85+
if (existing) {
86+
existing.pass += attributes.findings.pass;
87+
existing.fail += attributes.findings.fail;
88+
} else {
89+
aggregated.set(id, {
90+
id,
91+
displayName: PROVIDER_DISPLAY_NAMES[id] || id,
92+
pass: attributes.findings.pass,
93+
fail: attributes.findings.fail,
94+
});
95+
}
96+
}
97+
98+
return Array.from(aggregated.values());
99+
}
100+
101+
/**
102+
* Severity display names in order
103+
*/
104+
const SEVERITY_ORDER = [
105+
"Critical",
106+
"High",
107+
"Medium",
108+
"Low",
109+
"Informational",
110+
] as const;
111+
112+
/**
113+
* Adapts providers overview and findings severity API responses to Sankey chart format
114+
*
115+
* Creates a 2-level flow visualization:
116+
* - Level 1: Cloud providers (AWS, Azure, GCP, etc.)
117+
* - Level 2: Severity breakdown (Critical, High, Medium, Low, Informational)
118+
*
119+
* The severity distribution is calculated proportionally based on each provider's
120+
* fail count relative to the total fails across all providers.
121+
*
122+
* @param providersResponse - Raw API response from /overviews/providers
123+
* @param severityResponse - Raw API response from /overviews/findings_severity
124+
* @returns Sankey chart data with nodes and links
125+
*/
126+
export function adaptProvidersOverviewToSankey(
127+
providersResponse: ProvidersOverviewResponse | undefined,
128+
severityResponse?: FindingsSeverityOverviewResponse | undefined,
129+
): SankeyData {
130+
if (!providersResponse?.data || providersResponse.data.length === 0) {
131+
return { nodes: [], links: [] };
132+
}
133+
134+
// Aggregate providers by type
135+
const aggregatedProviders = aggregateProvidersByType(providersResponse.data);
136+
137+
// Filter out providers with no findings (only need fail > 0 for severity view)
138+
const providersWithFailures = aggregatedProviders.filter((p) => p.fail > 0);
139+
140+
if (providersWithFailures.length === 0) {
141+
return { nodes: [], links: [] };
142+
}
143+
144+
// Build nodes array: providers first, then severities
145+
const providerNodes: SankeyNode[] = providersWithFailures.map((p) => ({
146+
name: p.displayName,
147+
}));
148+
149+
const severityNodes: SankeyNode[] = SEVERITY_ORDER.map((severity) => ({
150+
name: severity,
151+
}));
152+
153+
const nodes = [...providerNodes, ...severityNodes];
154+
155+
// Calculate severity start index (after provider nodes)
156+
const severityStartIndex = providerNodes.length;
157+
158+
// Build links from each provider to severities
159+
const links: SankeyLink[] = [];
160+
161+
// If we have severity data, distribute proportionally
162+
if (severityResponse?.data?.attributes) {
163+
const { critical, high, medium, low, informational } =
164+
severityResponse.data.attributes;
165+
166+
const severityValues = [critical, high, medium, low, informational];
167+
const totalSeverity = severityValues.reduce((sum, v) => sum + v, 0);
168+
169+
if (totalSeverity > 0) {
170+
// Calculate total fails across all providers
171+
const totalFails = providersWithFailures.reduce(
172+
(sum, p) => sum + p.fail,
173+
0,
174+
);
175+
176+
providersWithFailures.forEach((provider, sourceIndex) => {
177+
// Calculate this provider's proportion of total fails
178+
const providerRatio = provider.fail / totalFails;
179+
180+
severityValues.forEach((severityValue, severityIndex) => {
181+
// Distribute severity proportionally to this provider
182+
const value = Math.round(severityValue * providerRatio);
183+
184+
if (value > 0) {
185+
links.push({
186+
source: sourceIndex,
187+
target: severityStartIndex + severityIndex,
188+
value,
189+
});
190+
}
191+
});
192+
});
193+
}
194+
} else {
195+
// Fallback: if no severity data, just show fail counts to a generic "Fail" node
196+
const failNode: SankeyNode = { name: "Fail" };
197+
nodes.push(failNode);
198+
const failIndex = nodes.length - 1;
199+
200+
providersWithFailures.forEach((provider, sourceIndex) => {
201+
links.push({
202+
source: sourceIndex,
203+
target: failIndex,
204+
value: provider.fail,
205+
});
206+
});
207+
}
208+
209+
return { nodes, links };
210+
}

ui/actions/overview/overview.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { redirect } from "next/navigation";
44
import { apiBaseUrl, getAuthHeaders } from "@/lib";
55
import { handleApiResponse } from "@/lib/server-actions-helper";
66

7-
import { ServicesOverviewResponse } from "./types";
7+
import {
8+
FindingsSeverityOverviewResponse,
9+
ProvidersOverviewResponse,
10+
ServicesOverviewResponse,
11+
} from "./types";
812

913
export const getServicesOverview = async ({
1014
filters = {},
@@ -39,7 +43,12 @@ export const getProvidersOverview = async ({
3943
query = "",
4044
sort = "",
4145
filters = {},
42-
}) => {
46+
}: {
47+
page?: number;
48+
query?: string;
49+
sort?: string;
50+
filters?: Record<string, string | string[] | undefined>;
51+
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
4352
const headers = await getAuthHeaders({ contentType: false });
4453

4554
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
@@ -52,7 +61,7 @@ export const getProvidersOverview = async ({
5261

5362
// Handle multiple filters
5463
Object.entries(filters).forEach(([key, value]) => {
55-
if (key !== "filter[search]") {
64+
if (key !== "filter[search]" && value !== undefined) {
5665
url.searchParams.append(key, String(value));
5766
}
5867
});
@@ -111,24 +120,21 @@ export const getFindingsByStatus = async ({
111120
};
112121

113122
export const getFindingsBySeverity = async ({
114-
page = 1,
115-
query = "",
116-
sort = "",
117123
filters = {},
118-
}) => {
124+
}: {
125+
filters?: Record<string, string | string[] | undefined>;
126+
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
119127
const headers = await getAuthHeaders({ contentType: false });
120128

121-
if (isNaN(Number(page)) || page < 1) redirect("/");
122-
123129
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
124130

125-
if (page) url.searchParams.append("page[number]", page.toString());
126-
if (query) url.searchParams.append("filter[search]", query);
127-
if (sort) url.searchParams.append("sort", sort);
128131
// Handle multiple filters, but exclude unsupported filters
129-
// The overviews/findings_severity endpoint does not support status or muted filters
130132
Object.entries(filters).forEach(([key, value]) => {
131-
if (key !== "filter[search]" && key !== "filter[muted]") {
133+
if (
134+
key !== "filter[search]" &&
135+
key !== "filter[muted]" &&
136+
value !== undefined
137+
) {
132138
url.searchParams.append(key, String(value));
133139
}
134140
});

ui/actions/overview/types.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
// Providers Overview Types
2+
// Corresponds to the /overviews/providers endpoint
3+
4+
export interface ProviderOverviewFindings {
5+
pass: number;
6+
fail: number;
7+
muted: number;
8+
total: number;
9+
}
10+
11+
export interface ProviderOverviewResources {
12+
total: number;
13+
}
14+
15+
export interface ProviderOverviewAttributes {
16+
findings: ProviderOverviewFindings;
17+
resources: ProviderOverviewResources;
18+
}
19+
20+
export interface ProviderOverview {
21+
type: "providers-overview";
22+
id: string;
23+
attributes: ProviderOverviewAttributes;
24+
}
25+
26+
export interface ProvidersOverviewResponse {
27+
data: ProviderOverview[];
28+
meta: {
29+
version: string;
30+
};
31+
}
32+
133
// Services Overview Types
234
// Corresponds to the /overviews/services endpoint
335

@@ -62,6 +94,30 @@ export interface ThreatScoreResponse {
6294
data: ThreatScoreSnapshot[];
6395
}
6496

97+
// Findings Severity Overview Types
98+
// Corresponds to the /overviews/findings_severity endpoint
99+
100+
export interface FindingsSeverityAttributes {
101+
critical: number;
102+
high: number;
103+
medium: number;
104+
low: number;
105+
informational: number;
106+
}
107+
108+
export interface FindingsSeverityOverview {
109+
type: "findings-severity-overview";
110+
id: string;
111+
attributes: FindingsSeverityAttributes;
112+
}
113+
114+
export interface FindingsSeverityOverviewResponse {
115+
data: FindingsSeverityOverview;
116+
meta: {
117+
version: string;
118+
};
119+
}
120+
65121
// Filters for ThreatScore endpoint
66122
export interface ThreatScoreFilters {
67123
snapshot_id?: string;

0 commit comments

Comments
 (0)