mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): adapt risk pipeline sankey layout (#11527)
This commit is contained in:
committed by
GitHub
parent
285974b7d4
commit
f1d741214a
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user