From f1d741214a60df17158c3fdc97804fd1fde64f3a Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:44:17 +0200 Subject: [PATCH] fix(ui): adapt risk pipeline sankey layout (#11527) --- ui/CHANGELOG.md | 4 + .../graphs/sankey-chart.layout.test.ts | 84 ++++++++++++++++ ui/components/graphs/sankey-chart.layout.ts | 70 +++++++++++++ ui/components/graphs/sankey-chart.test.tsx | 98 +++++++++++++++++++ ui/components/graphs/sankey-chart.tsx | 28 ++++-- 5 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 ui/components/graphs/sankey-chart.layout.test.ts create mode 100644 ui/components/graphs/sankey-chart.layout.ts create mode 100644 ui/components/graphs/sankey-chart.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2676778fef..67a0a59e5d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) diff --git a/ui/components/graphs/sankey-chart.layout.test.ts b/ui/components/graphs/sankey-chart.layout.test.ts new file mode 100644 index 0000000000..95fe9b624e --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.test.ts @@ -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, + }); + }); +}); diff --git a/ui/components/graphs/sankey-chart.layout.ts b/ui/components/graphs/sankey-chart.layout.ts new file mode 100644 index 0000000000..fd8ba39562 --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.ts @@ -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(); + + 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), + }; +}; diff --git a/ui/components/graphs/sankey-chart.test.tsx b/ui/components/graphs/sankey-chart.test.tsx new file mode 100644 index 0000000000..48c9e01a04 --- /dev/null +++ b/ui/components/graphs/sankey-chart.test.tsx @@ -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( + , + ); + + 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( + + + , + ); + + 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); + }); +}); diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index 0a41d0e4e7..f65647d73e 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -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 (
@@ -549,12 +563,12 @@ export function SankeyChart({ return (
- +