fix(ui): adapt risk pipeline sankey layout (#11527)

This commit is contained in:
Hugo Pereira Brito
2026-06-11 09:44:17 +02:00
committed by GitHub
parent 285974b7d4
commit f1d741214a
5 changed files with 277 additions and 7 deletions
+4
View File
@@ -14,6 +14,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508)
- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554)
### 🐞 Fixed
- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527)
---
## [1.29.3] (Prowler v5.29.3)
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { getSankeyLayoutConfig } from "./sankey-chart.layout";
describe("getSankeyLayoutConfig", () => {
it("keeps default size when provider count is at baseline", () => {
const config = getSankeyLayoutConfig({
baseHeight: 460,
nodes: [
{ name: "AWS" },
{ name: "High" },
{ name: "Medium" },
{ name: "Low" },
{ name: "Azure" },
{ name: "Info" },
{ name: "GCP" },
],
links: [{ source: 0 }, { source: 4 }, { source: 6 }],
});
expect(config).toEqual({
height: 460,
nodePadding: 50,
});
});
it("increases height and reduces node padding for denser graphs", () => {
const config = getSankeyLayoutConfig({
baseHeight: 460,
nodes: Array.from({ length: 24 }, (_, index) => ({
name: `Provider ${index}`,
})),
links: [
{ source: 0 },
{ source: 1 },
{ source: 2 },
{ source: 3 },
{ source: 4 },
{ source: 5 },
{ source: 6 },
{ source: 7 },
{ source: 8 },
{ source: 9 },
{ source: 10 },
{ source: 11 },
],
});
expect(config).toEqual({
height: 844,
nodePadding: 38,
});
});
it("clamps padding to minimum when provider count is very large", () => {
const config = getSankeyLayoutConfig({
baseHeight: 460,
nodes: Array.from({ length: 120 }, (_, index) => ({
name: `Provider ${index}`,
})),
links: Array.from({ length: 100 }, (_, index) => ({
source: index,
})),
});
expect(config.nodePadding).toBe(14);
expect(config.height).toBe(1400);
});
it("falls back to node-based provider estimation when no link sources exist", () => {
const config = getSankeyLayoutConfig({
baseHeight: 460,
nodes: Array.from({ length: 8 }, (_, index) => ({
name: `Node ${index}`,
})),
links: [],
});
expect(config).toEqual({
height: 460,
nodePadding: 50,
});
});
});
@@ -0,0 +1,70 @@
export interface SankeyNodeLike {
name: string;
}
export interface SankeyLinkLike {
source: number;
}
export interface SankeyLayoutInput {
baseHeight: number;
nodes: SankeyNodeLike[];
links: SankeyLinkLike[];
}
export interface SankeyLayoutConfig {
height: number;
nodePadding: number;
}
const SANKEY_DEFAULT_NODE_PADDING = 50;
const SANKEY_MIN_NODE_PADDING = 14;
const SANKEY_HEIGHT_GROWTH_PER_PROVIDER = 64;
const SANKEY_BASE_PROVIDER_COUNT = 6;
const SANKEY_MAX_HEIGHT = 1400;
const getProviderNodeCount = ({ nodes, links }: SankeyLayoutInput): number => {
const uniqueSourceIndexes = new Set<number>();
links.forEach((link) => {
uniqueSourceIndexes.add(link.source);
});
if (uniqueSourceIndexes.size > 0) {
return uniqueSourceIndexes.size;
}
return Math.max(0, nodes.length - 5);
};
const getNodePaddingForProviderCount = (providerNodeCount: number): number => {
const compactedProviders = Math.max(
0,
providerNodeCount - SANKEY_BASE_PROVIDER_COUNT,
);
return Math.max(
SANKEY_MIN_NODE_PADDING,
Math.round(SANKEY_DEFAULT_NODE_PADDING - compactedProviders * 2),
);
};
export const getSankeyLayoutConfig = (
params: SankeyLayoutInput,
): SankeyLayoutConfig => {
const providerNodeCount = getProviderNodeCount(params);
const extraProviders = Math.max(
0,
providerNodeCount - SANKEY_BASE_PROVIDER_COUNT,
);
const dynamicHeight = Math.min(
SANKEY_MAX_HEIGHT,
Math.round(
params.baseHeight + extraProviders * SANKEY_HEIGHT_GROWTH_PER_PROVIDER,
),
);
return {
height: dynamicHeight,
nodePadding: getNodePaddingForProviderCount(providerNodeCount),
};
};
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CustomNode, SankeyChart } from "./sankey-chart";
import { getSankeyLayoutConfig } from "./sankey-chart.layout";
const mockPush = vi.fn();
vi.mock("@/lib", () => ({
applyFailNonMutedFilters: (filters: unknown) => filters,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => new URLSearchParams(),
}));
describe("SankeyChart", () => {
it("uses layout-configured height for the empty-state container", () => {
const data = {
nodes: Array.from({ length: 14 }, (_, index) => ({
name: `Node ${index}`,
})),
links: Array.from({ length: 12 }, (_, index) => ({
source: index,
target: 13,
value: 0,
})),
};
const baseHeight = 460;
const layoutConfig = getSankeyLayoutConfig({
baseHeight,
nodes: data.nodes,
links: data.links,
});
const { container } = render(
<SankeyChart data={data} height={baseHeight} />,
);
expect(
screen.getByText("No failed findings to display"),
).toBeInTheDocument();
expect(container.firstElementChild).toHaveStyle({
height: `${layoutConfig.height}px`,
});
});
it("renders risk and severity node labels with middle-aligned text", () => {
const x = 10;
const y = 20;
const width = 70;
const height = 80;
const nodeCenterY = y + height / 2;
render(
<svg>
<CustomNode
x={x}
y={y}
width={width}
height={height}
payload={{ name: "High", value: 9, newFindings: 3, change: 1 }}
containerWidth={200}
colors={{ High: "#ff0000" }}
/>
</svg>,
);
const textElements = screen.getAllByText(/High|9/);
const nameLabel = textElements.find(
(element) => element.textContent === "High",
);
const valueLabel = textElements.find(
(element) => element.textContent === "9",
);
expect(nameLabel).toBeDefined();
expect(valueLabel).toBeDefined();
if (!nameLabel || !valueLabel) {
throw new Error(
"Expected both node name and value labels to be rendered.",
);
}
expect(nameLabel).toHaveAttribute("dominant-baseline", "middle");
expect(valueLabel).toHaveAttribute("dominant-baseline", "middle");
const nameY = Number.parseFloat(nameLabel.getAttribute("y") || "0");
const valueY = Number.parseFloat(valueLabel.getAttribute("y") || "0");
expect(nameY).toBeLessThan(nodeCenterY);
expect(valueY).toBeGreaterThan(nodeCenterY);
expect((nameY + valueY) / 2).toBeCloseTo(nodeCenterY);
});
});
+21 -7
View File
@@ -11,6 +11,7 @@ import { initializeChartColors } from "@/lib/charts/colors";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { getSankeyLayoutConfig } from "./sankey-chart.layout";
import { ChartTooltip } from "./shared/chart-tooltip";
// Reverse mapping from display name to provider type for URL filters
@@ -73,6 +74,7 @@ interface NodeTooltipState {
const TOOLTIP_OFFSET_PX = 10;
const MIN_LINK_WIDTH = 4;
const NODE_LABEL_LINE_SPACING = 13;
interface TooltipPayload {
payload: {
@@ -88,7 +90,7 @@ interface TooltipProps {
payload?: TooltipPayload[];
}
interface CustomNodeProps {
export interface CustomNodeProps {
x: number;
y: number;
width: number;
@@ -148,7 +150,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => {
return null;
};
const CustomNode = ({
export const CustomNode = ({
x,
y,
width,
@@ -215,6 +217,10 @@ const CustomNode = ({
const iconSize = 24;
const iconGap = 8;
const nodeCenterY = y + height / 2;
const nodeNameY = nodeCenterY - NODE_LABEL_LINE_SPACING / 2;
const nodeValueY = nodeCenterY + NODE_LABEL_LINE_SPACING / 2;
// Calculate text position accounting for icon
const textOffsetX = isOut ? x - 6 : x + width + 6;
const iconOffsetX = isOut
@@ -260,8 +266,9 @@ const CustomNode = ({
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2}
y={nodeNameY}
fontSize="14"
dominantBaseline="middle"
fill="var(--color-text-neutral-primary)"
>
{nodeName}
@@ -275,8 +282,9 @@ const CustomNode = ({
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2 + 13}
y={nodeValueY}
fontSize="12"
dominantBaseline="middle"
fill="var(--color-text-neutral-secondary)"
>
{payload.value}
@@ -531,11 +539,17 @@ export function SankeyChart({
// Check if there's actual data to display (links with values > 0)
const hasData = data.links.some((link) => link.value > 0);
const layoutConfig = getSankeyLayoutConfig({
baseHeight: height,
nodes: data.nodes,
links: data.links,
});
if (!hasData) {
return (
<div
className="flex items-center justify-center"
style={{ height: `${height}px` }}
style={{ height: `${layoutConfig.height}px` }}
>
<div className="flex flex-col items-center gap-2 text-center">
<Info size={48} className="text-text-neutral-tertiary" />
@@ -549,12 +563,12 @@ export function SankeyChart({
return (
<div className="relative">
<ResponsiveContainer width="100%" height={height}>
<ResponsiveContainer width="100%" height={layoutConfig.height}>
<Sankey
data={data}
node={wrappedCustomNode}
link={wrappedCustomLink}
nodePadding={50}
nodePadding={layoutConfig.nodePadding}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
sort={false}
>