feat(ui): add Risk Pipeline View with Sankey chart to Overview page (#9320)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2025-11-26 13:33:58 +01:00
committed by GitHub
parent 880345bebe
commit 4e9dd46a5e
17 changed files with 610 additions and 222 deletions

View File

@@ -1,10 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import { IconSvgProps } from "@/types";
import { ChartTooltip } from "./shared/chart-tooltip";
// Map node names to their corresponding provider icon components
const PROVIDER_ICONS: Record<string, React.FC<IconSvgProps>> = {
AWS: AWSProviderBadge,
Azure: AzureProviderBadge,
"Google Cloud": GCPProviderBadge,
Kubernetes: KS8ProviderBadge,
"Microsoft 365": M365ProviderBadge,
GitHub: GitHubProviderBadge,
"Infrastructure as Code": IacProviderBadge,
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
};
interface SankeyNode {
name: string;
newFindings?: number;
@@ -47,14 +72,33 @@ interface NodeTooltipState {
}
const TOOLTIP_OFFSET_PX = 10;
const MIN_LINK_WIDTH = 4;
// Map severity node names to their filter values for the findings page
const SEVERITY_FILTER_MAP: Record<string, string> = {
Critical: "critical",
High: "high",
Medium: "medium",
Low: "low",
Informational: "informational",
};
// Map color names to CSS variable names defined in globals.css
const COLOR_MAP: Record<string, string> = {
// Status colors
Success: "--color-bg-pass",
Pass: "--color-bg-pass",
Fail: "--color-bg-fail",
// Provider colors
AWS: "--color-bg-data-aws",
Azure: "--color-bg-data-azure",
"Google Cloud": "--color-bg-data-gcp",
Kubernetes: "--color-bg-data-kubernetes",
"Microsoft 365": "--color-bg-data-m365",
GitHub: "--color-bg-data-github",
"Infrastructure as Code": "--color-bg-data-muted",
"Oracle Cloud Infrastructure": "--color-bg-data-muted",
// Severity colors
Critical: "--color-bg-data-critical",
High: "--color-bg-data-high",
Medium: "--color-bg-data-medium",
@@ -130,6 +174,7 @@ interface CustomNodeProps {
onNodeHover?: (data: Omit<NodeTooltipState, "show">) => void;
onNodeMove?: (position: { x: number; y: number }) => void;
onNodeLeave?: () => void;
onNodeClick?: (nodeName: string) => void;
}
interface CustomLinkProps {
@@ -184,12 +229,14 @@ const CustomNode = ({
onNodeHover,
onNodeMove,
onNodeLeave,
onNodeClick,
}: CustomNodeProps) => {
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
@@ -227,12 +274,30 @@ const CustomNode = ({
onNodeLeave?.();
};
const handleClick = () => {
if (isClickable) {
onNodeClick?.(nodeName);
}
};
const IconComponent = PROVIDER_ICONS[nodeName];
const hasIcon = IconComponent !== undefined;
const iconSize = 24;
const iconGap = 8;
// Calculate text position accounting for icon
const textOffsetX = isOut ? x - 6 : x + width + 6;
const iconOffsetX = isOut
? textOffsetX - iconSize - iconGap
: textOffsetX + iconGap;
return (
<g
style={{ cursor: hasTooltip ? "pointer" : "default" }}
style={{ cursor: isClickable || hasTooltip ? "pointer" : "default" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<Rectangle
x={x}
@@ -244,9 +309,27 @@ const CustomNode = ({
/>
{!isHidden && (
<>
{hasIcon && (
<foreignObject
x={isOut ? iconOffsetX : textOffsetX}
y={y + height / 2 - iconSize / 2 - 2}
width={iconSize}
height={iconSize}
>
<div className="flex items-center justify-center">
<IconComponent width={iconSize} height={iconSize} />
</div>
</foreignObject>
)}
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
x={
hasIcon
? isOut
? iconOffsetX - iconGap
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2}
fontSize="14"
fill="var(--color-text-neutral-primary)"
@@ -255,7 +338,13 @@ const CustomNode = ({
</text>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
x={
hasIcon
? isOut
? iconOffsetX - iconGap
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2 + 13}
fontSize="12"
fill="var(--color-text-neutral-secondary)"
@@ -293,15 +382,18 @@ const CustomLink = ({
const isHovered = hoveredLink !== null && hoveredLink === index;
const hasHoveredLink = hoveredLink !== null;
// Ensure minimum link width for better visibility of small values
const effectiveLinkWidth = Math.max(linkWidth, MIN_LINK_WIDTH);
const pathD = `
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
M${sourceX},${sourceY + effectiveLinkWidth / 2}
C${sourceControlX},${sourceY + effectiveLinkWidth / 2}
${targetControlX},${targetY + effectiveLinkWidth / 2}
${targetX},${targetY + effectiveLinkWidth / 2}
L${targetX},${targetY - effectiveLinkWidth / 2}
C${targetControlX},${targetY - effectiveLinkWidth / 2}
${sourceControlX},${sourceY - effectiveLinkWidth / 2}
${sourceX},${sourceY - effectiveLinkWidth / 2}
Z
`;
@@ -360,6 +452,7 @@ const CustomLink = ({
};
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const router = useRouter();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [colors, setColors] = useState<Record<string, string>>({});
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
@@ -423,11 +516,18 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
setNodeTooltip((prev) => ({ ...prev, show: false }));
};
const handleNodeClick = (nodeName: string) => {
const severityFilter = SEVERITY_FILTER_MAP[nodeName];
if (severityFilter) {
router.push(`/findings?filter[severity]=${severityFilter}`);
}
};
// Create callback references that wrap custom props and Recharts-injected props
const wrappedCustomNode = (
props: Omit<
CustomNodeProps,
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave"
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave" | "onNodeClick"
>,
) => (
<CustomNode
@@ -436,6 +536,7 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
/>
);