"use client"; import { Info } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; import { PROVIDER_ICONS } from "@/components/icons/providers-badge"; import { initializeChartColors } from "@/lib/charts/colors"; import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; import { ChartTooltip } from "./shared/chart-tooltip"; // Reverse mapping from display name to provider type for URL filters const PROVIDER_TYPE_MAP: Record = Object.entries( PROVIDER_DISPLAY_NAMES, ).reduce( (acc, [type, displayName]) => { acc[displayName] = type; return acc; }, {} as Record, ); interface SankeyNode { name: string; newFindings?: number; change?: number; } interface SankeyLink { source: number; target: number; value: number; } interface ZeroDataProvider { id: string; displayName: string; } interface SankeyChartProps { data: { nodes: SankeyNode[]; links: SankeyLink[]; }; zeroDataProviders?: ZeroDataProvider[]; height?: number; } interface LinkTooltipState { show: boolean; x: number; y: number; sourceName: string; targetName: string; value: number; color: string; } interface NodeTooltipState { show: boolean; x: number; y: number; name: string; value: number; color: string; newFindings?: number; change?: number; } const TOOLTIP_OFFSET_PX = 10; const MIN_LINK_WIDTH = 4; interface TooltipPayload { payload: { source?: { name: string }; target?: { name: string }; value?: number; name?: string; }; } interface TooltipProps { active?: boolean; payload?: TooltipPayload[]; } interface CustomNodeProps { x: number; y: number; width: number; height: number; payload: SankeyNode & { value: number; newFindings?: number; change?: number; }; containerWidth: number; colors: Record; onNodeHover?: (data: Omit) => void; onNodeMove?: (position: { x: number; y: number }) => void; onNodeLeave?: () => void; onNodeClick?: (nodeName: string) => void; } interface CustomLinkProps { sourceX: number; targetX: number; sourceY: number; targetY: number; sourceControlX: number; targetControlX: number; linkWidth: number; index: number; payload: { source?: { name: string }; target?: { name: string }; value?: number; }; hoveredLink: number | null; colors: Record; onLinkHover?: (index: number, data: Omit) => void; onLinkMove?: (position: { x: number; y: number }) => void; onLinkLeave?: () => void; onLinkClick?: (sourceName: string, targetName: string) => void; } const CustomTooltip = ({ active, payload }: TooltipProps) => { if (active && payload && payload.length) { const data = payload[0].payload; const sourceName = data.source?.name || data.name; const targetName = data.target?.name; const value = data.value; return (

{sourceName} {targetName ? ` → ${targetName}` : ""}

{value &&

{value}

}
); } return null; }; const CustomNode = ({ x, y, width, height, payload, containerWidth, colors, 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; const rect = e.currentTarget.closest("svg") as SVGSVGElement; if (rect) { const bbox = rect.getBoundingClientRect(); onNodeHover?.({ x: e.clientX - bbox.left, y: e.clientY - bbox.top, name: nodeName, value: payload.value, color, newFindings: payload.newFindings, change: payload.change, }); } }; const handleMouseMove = (e: React.MouseEvent) => { if (!hasTooltip) return; const rect = e.currentTarget.closest("svg") as SVGSVGElement; if (rect) { const bbox = rect.getBoundingClientRect(); onNodeMove?.({ x: e.clientX - bbox.left, y: e.clientY - bbox.top, }); } }; const handleMouseLeave = () => { if (!hasTooltip) return; 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 ( {!isHidden && ( <> {hasIcon && (
)} {nodeName} {payload.value} )}
); }; const CustomLink = ({ sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX, linkWidth, index, payload, hoveredLink, colors, onLinkHover, onLinkMove, onLinkLeave, onLinkClick, }: CustomLinkProps) => { const sourceName = payload.source?.name || ""; const targetName = payload.target?.name || ""; const value = payload.value || 0; const color = colors[sourceName] || "var(--color-text-neutral-tertiary)"; const isHidden = targetName === ""; 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 + 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 `; const getOpacity = () => { if (isHidden) return "0"; if (!hasHoveredLink) return "0.4"; return isHovered ? "0.8" : "0.1"; }; const handleMouseEnter = (e: React.MouseEvent) => { const rect = e.currentTarget.parentElement?.parentElement ?.parentElement as unknown as SVGSVGElement; if (rect) { const bbox = rect.getBoundingClientRect(); onLinkHover?.(index, { x: e.clientX - bbox.left, y: e.clientY - bbox.top, sourceName, targetName, value, color, }); } }; const handleMouseMove = (e: React.MouseEvent) => { const rect = e.currentTarget.parentElement?.parentElement ?.parentElement as unknown as SVGSVGElement; if (rect && isHovered) { const bbox = rect.getBoundingClientRect(); onLinkMove?.({ x: e.clientX - bbox.left, y: e.clientY - bbox.top, }); } }; const handleMouseLeave = () => { onLinkLeave?.(); }; const handleClick = () => { if (!isHidden && onLinkClick) { onLinkClick(sourceName, targetName); } }; return ( ); }; export function SankeyChart({ data, zeroDataProviders = [], height = 400, }: SankeyChartProps) { const router = useRouter(); const searchParams = useSearchParams(); const [hoveredLink, setHoveredLink] = useState(null); const [colors, setColors] = useState>({}); const [linkTooltip, setLinkTooltip] = useState({ show: false, x: 0, y: 0, sourceName: "", targetName: "", value: 0, color: "", }); const [nodeTooltip, setNodeTooltip] = useState({ show: false, x: 0, y: 0, name: "", value: 0, color: "", }); // Initialize colors from CSS variables on mount useEffect(() => { setColors(initializeChartColors()); }, []); const handleLinkHover = ( index: number, data: Omit, ) => { setHoveredLink(index); setLinkTooltip({ show: true, ...data }); }; const handleLinkMove = (position: { x: number; y: number }) => { setLinkTooltip((prev) => ({ ...prev, x: position.x, y: position.y, })); }; const handleLinkLeave = () => { setHoveredLink(null); setLinkTooltip((prev) => ({ ...prev, show: false })); }; const handleNodeHover = (data: Omit) => { setNodeTooltip({ show: true, ...data }); }; const handleNodeMove = (position: { x: number; y: number }) => { setNodeTooltip((prev) => ({ ...prev, x: position.x, y: position.y, })); }; const handleNodeLeave = () => { setNodeTooltip((prev) => ({ ...prev, show: false })); }; const handleNodeClick = (nodeName: string) => { const severityFilter = SEVERITY_FILTER_MAP[nodeName]; if (severityFilter) { const params = new URLSearchParams(searchParams.toString()); mapProviderFiltersForFindings(params); params.set("filter[severity__in]", severityFilter); params.set("filter[status__in]", "FAIL"); params.set("filter[muted]", "false"); router.push(`/findings?${params.toString()}`); } }; const handleLinkClick = (sourceName: string, targetName: string) => { const providerType = PROVIDER_TYPE_MAP[sourceName]; const severityFilter = SEVERITY_FILTER_MAP[targetName]; if (severityFilter) { const params = new URLSearchParams(searchParams.toString()); mapProviderFiltersForFindings(params); // Always set provider_type filter based on the clicked link's source (provider) // This ensures clicking "AWS → High" filters by AWS even when no global filter is set const hasProviderIdFilter = searchParams.has("filter[provider_id__in]"); if (providerType && !hasProviderIdFilter) { params.set("filter[provider_type__in]", providerType); } params.set("filter[severity__in]", severityFilter); params.set("filter[status__in]", "FAIL"); params.set("filter[muted]", "false"); router.push(`/findings?${params.toString()}`); } }; // Create callback references that wrap custom props and Recharts-injected props const wrappedCustomNode = ( props: Omit< CustomNodeProps, "colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave" | "onNodeClick" >, ) => ( ); const wrappedCustomLink = ( props: Omit< CustomLinkProps, | "colors" | "hoveredLink" | "onLinkHover" | "onLinkMove" | "onLinkLeave" | "onLinkClick" >, ) => ( ); // Check if there's actual data to display (links with values > 0) const hasData = data.links.some((link) => link.value > 0); if (!hasData) { return (

No failed findings to display

); } return (
} /> {linkTooltip.show && (
)} {nodeTooltip.show && (
)} {zeroDataProviders.length > 0 && (

Providers with no failed findings

{zeroDataProviders.map((provider) => { const IconComponent = PROVIDER_ICONS[provider.displayName]; return (
{IconComponent && } {provider.displayName}
); })}
)}
); }