mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
refactor(ui): enhance SankeyChart with interactive tooltips and remove BarChart
- Remove BarChart component (no longer needed) - Add interactive hover states and tooltips to SankeyChart - Implement link and node highlighting on hover - Add PROVIDER_COLORS and STATUS_COLORS constants - Update Google provider color to official brand color
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartTooltip } from "./shared/ChartTooltip";
|
||||
import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint, LayoutOption } from "./types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: BarDataPoint[];
|
||||
layout?: LayoutOption;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
const CustomLabel = ({ x, y, width, height, value, data }: any) => {
|
||||
const percentage = data.percentage;
|
||||
return (
|
||||
<text
|
||||
x={x + width + 10}
|
||||
y={y + height / 2}
|
||||
fill={CHART_COLORS.textSecondary}
|
||||
fontSize={12}
|
||||
textAnchor="start"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{percentage !== undefined
|
||||
? `${percentage}% • ${value.toLocaleString()}`
|
||||
: value.toLocaleString()}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
layout = LAYOUT_OPTIONS.horizontal,
|
||||
xLabel,
|
||||
yLabel,
|
||||
height = 400,
|
||||
showValues = true,
|
||||
}: BarChartProps) {
|
||||
const isHorizontal = layout === LAYOUT_OPTIONS.horizontal;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBar
|
||||
data={data}
|
||||
layout={layout}
|
||||
margin={{ top: 20, right: showValues ? 100 : 30, left: 20, bottom: 20 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={CHART_COLORS.gridLine}
|
||||
horizontal={isHorizontal}
|
||||
vertical={!isHorizontal}
|
||||
/>
|
||||
{isHorizontal ? (
|
||||
<>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={100}
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={4}
|
||||
label={
|
||||
showValues && isHorizontal
|
||||
? (props: any) => (
|
||||
<CustomLabel {...props} data={data[props.index]} />
|
||||
)
|
||||
: false
|
||||
}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
entry.color ||
|
||||
getSeverityColorByName(entry.name) ||
|
||||
CHART_COLORS.defaultColor
|
||||
}
|
||||
opacity={1}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { ChartTooltip } from "./shared/ChartTooltip";
|
||||
|
||||
interface SankeyNode {
|
||||
name: string;
|
||||
newFindings?: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
interface SankeyLink {
|
||||
@@ -22,13 +26,40 @@ interface SankeyChartProps {
|
||||
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;
|
||||
}
|
||||
|
||||
// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables
|
||||
const COLORS: Record<string, string> = {
|
||||
Success: "var(--chart-success-color)",
|
||||
Fail: "var(--chart-fail)",
|
||||
AWS: "var(--chart-provider-aws)",
|
||||
Azure: "var(--chart-provider-azure)",
|
||||
Google: "var(--chart-provider-google)",
|
||||
...SEVERITY_COLORS,
|
||||
Success: "#86da26",
|
||||
Fail: "#db2b49",
|
||||
AWS: "#ff9900",
|
||||
Azure: "#00bcd4",
|
||||
Google: "#EA4335",
|
||||
Critical: "#971348",
|
||||
High: "#ff3077",
|
||||
Medium: "#ff7d19",
|
||||
Low: "#fdd34f",
|
||||
Info: "#2e51b2",
|
||||
Informational: "#2e51b2",
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
@@ -46,40 +77,88 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
|
||||
const CustomNode = (props: any) => {
|
||||
const { x, y, width, height, payload, containerWidth } = props;
|
||||
const isOut = x + width + 6 > containerWidth;
|
||||
const nodeName = payload.name;
|
||||
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
|
||||
const isHidden = nodeName === "";
|
||||
const hasTooltip = !isHidden && payload.newFindings;
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
if (!hasTooltip) return;
|
||||
|
||||
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
|
||||
if (rect) {
|
||||
const bbox = rect.getBoundingClientRect();
|
||||
props.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();
|
||||
props.onNodeMove?.({
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasTooltip) return;
|
||||
props.onNodeLeave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<g>
|
||||
<g
|
||||
style={{ cursor: hasTooltip ? "pointer" : "default" }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Rectangle
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={color}
|
||||
fillOpacity="1"
|
||||
fillOpacity={isHidden ? "0" : "1"}
|
||||
/>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2}
|
||||
fontSize="14"
|
||||
className="fill-white stroke-white"
|
||||
>
|
||||
{nodeName}
|
||||
</text>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2 + 13}
|
||||
fontSize="12"
|
||||
className="fill-slate-400 stroke-slate-400"
|
||||
strokeOpacity="0.5"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
{!isHidden && (
|
||||
<>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2}
|
||||
fontSize="14"
|
||||
className="fill-white stroke-white"
|
||||
>
|
||||
{nodeName}
|
||||
</text>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2 + 13}
|
||||
fontSize="12"
|
||||
className="fill-slate-400 stroke-slate-400"
|
||||
strokeOpacity="0.5"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
@@ -93,45 +172,221 @@ const CustomLink = (props: any) => {
|
||||
sourceControlX,
|
||||
targetControlX,
|
||||
linkWidth,
|
||||
index,
|
||||
} = props;
|
||||
|
||||
const sourceName = props.payload.source?.name || "";
|
||||
const targetName = props.payload.target?.name || "";
|
||||
const value = props.payload.value || 0;
|
||||
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
|
||||
const isHidden = targetName === "";
|
||||
|
||||
const isHovered =
|
||||
props.hoveredLink !== null && props.hoveredLink === index;
|
||||
const hasHoveredLink = props.hoveredLink !== null;
|
||||
|
||||
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}
|
||||
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();
|
||||
props.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();
|
||||
props.onLinkMove?.({
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
props.onLinkLeave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
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}
|
||||
Z
|
||||
`}
|
||||
d={pathD}
|
||||
fill={color}
|
||||
fillOpacity="0.4"
|
||||
fillOpacity={getOpacity()}
|
||||
stroke="none"
|
||||
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
sourceName: "",
|
||||
targetName: "",
|
||||
value: 0,
|
||||
color: "",
|
||||
});
|
||||
|
||||
const [nodeTooltip, setNodeTooltip] = useState<NodeTooltipState>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
name: "",
|
||||
value: 0,
|
||||
color: "",
|
||||
});
|
||||
|
||||
const handleLinkHover = (
|
||||
index: number,
|
||||
data: Omit<LinkTooltipState, "show">,
|
||||
) => {
|
||||
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<NodeTooltipState, "show">) => {
|
||||
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 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Sankey
|
||||
data={data}
|
||||
node={<CustomNode />}
|
||||
link={<CustomLink />}
|
||||
nodePadding={50}
|
||||
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Sankey>
|
||||
</ResponsiveContainer>
|
||||
<div className="relative">
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Sankey
|
||||
data={data}
|
||||
node={
|
||||
<CustomNode
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeMove={handleNodeMove}
|
||||
onNodeLeave={handleNodeLeave}
|
||||
/>
|
||||
}
|
||||
link={
|
||||
<CustomLink
|
||||
hoveredLink={hoveredLink}
|
||||
onLinkHover={handleLinkHover}
|
||||
onLinkMove={handleLinkMove}
|
||||
onLinkLeave={handleLinkLeave}
|
||||
/>
|
||||
}
|
||||
nodePadding={50}
|
||||
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
|
||||
sort={false}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Sankey>
|
||||
</ResponsiveContainer>
|
||||
{linkTooltip.show && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50"
|
||||
style={{
|
||||
left: `${Math.max(125, Math.min(linkTooltip.x, window.innerWidth - 125))}px`,
|
||||
top: `${Math.max(linkTooltip.y - 80, 10)}px`,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
<ChartTooltip
|
||||
active={true}
|
||||
payload={[
|
||||
{
|
||||
payload: {
|
||||
name: linkTooltip.targetName,
|
||||
value: linkTooltip.value,
|
||||
color: linkTooltip.color,
|
||||
},
|
||||
color: linkTooltip.color,
|
||||
},
|
||||
]}
|
||||
label={`${linkTooltip.sourceName} → ${linkTooltip.targetName}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nodeTooltip.show && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50"
|
||||
style={{
|
||||
left: `${Math.max(125, Math.min(nodeTooltip.x, window.innerWidth - 125))}px`,
|
||||
top: `${Math.max(nodeTooltip.y - 80, 10)}px`,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
<ChartTooltip
|
||||
active={true}
|
||||
payload={[
|
||||
{
|
||||
payload: {
|
||||
name: nodeTooltip.name,
|
||||
value: nodeTooltip.value,
|
||||
color: nodeTooltip.color,
|
||||
newFindings: nodeTooltip.newFindings,
|
||||
change: nodeTooltip.change,
|
||||
},
|
||||
color: nodeTooltip.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { DonutChart } from "./DonutChart";
|
||||
export { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
|
||||
@@ -7,6 +7,17 @@ export const SEVERITY_COLORS = {
|
||||
Critical: "var(--chart-danger-emphasis)",
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_COLORS = {
|
||||
AWS: "var(--chart-provider-aws)",
|
||||
Azure: "var(--chart-provider-azure)",
|
||||
Google: "var(--chart-provider-google)",
|
||||
} as const;
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
Success: "var(--chart-success-color)",
|
||||
Fail: "var(--chart-fail)",
|
||||
} as const;
|
||||
|
||||
export const CHART_COLORS = {
|
||||
tooltipBorder: "var(--chart-border-emphasis)",
|
||||
tooltipBackground: "var(--chart-background)",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
/* Chart Provider Colors */
|
||||
--chart-provider-aws: #ff9900;
|
||||
--chart-provider-azure: #00bcd4;
|
||||
--chart-provider-google: #db2b49;
|
||||
--chart-provider-google: #EA4335;
|
||||
|
||||
/* Chart UI Colors */
|
||||
--chart-text-secondary: #94a3b8;
|
||||
|
||||
Reference in New Issue
Block a user