Compare commits

...

4 Commits

Author SHA1 Message Date
Alan Buscaglia 5f432f8f79 fix(ui): align attack paths node label dimensions
- Share node dimensions between graph nodes, layout, and export
- Move label wrapping utilities into query builder lib
- Use node-specific text widths in PNG export
2026-05-18 18:17:26 +02:00
Alan Buscaglia 0d25c1f369 Merge remote-tracking branch 'origin/fix/attack-paths-node-label-truncation' into fix/attack-paths-node-label-truncation 2026-05-18 14:04:16 +02:00
Alan Buscaglia e204cc4063 fix(ui): improve attack paths node labels
- Wrap long graph node labels and show truncation with ellipsis
- Add immediate tooltips for truncated node values
- Keep PNG export and Dagre dimensions aligned
2026-05-18 14:01:49 +02:00
Alan Buscaglia cddf1de6f8 fix(ui): improve attack paths node labels
- Wrap long graph node labels and show truncation with ellipsis
- Add immediate tooltips for truncated node values
- Keep PNG export and Dagre dimensions aligned
2026-05-18 13:47:09 +02:00
13 changed files with 450 additions and 249 deletions
+1
View File
@@ -20,6 +20,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Mute Findings modal now enforces the 100-character limit on the rule name input with a live counter and inline error, matching the existing reason field behaviour [(#11158)](https://github.com/prowler-cloud/prowler/pull/11158)
- Attack Paths graph nodes now wrap long resource and finding labels, indicate truncated values with `…`, and show the full value in an immediate tooltip [(#11197)](https://github.com/prowler-cloud/prowler/pull/11197)
### 🔐 Security
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
@@ -91,5 +92,26 @@ describe("FindingNode", () => {
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("medium")).toBeInTheDocument();
});
it("should expose the full finding title as an immediate tooltip when truncated", async () => {
// Given
const title =
"Ensure administrator access policies are rotated regularly";
const props = buildNodeProps(buildFindingNode("high", title));
// When
render(<FindingNode {...props} />);
// Then
expect(screen.getByText("Ensure")).toBeInTheDocument();
expect(screen.getByText("administrator")).toBeInTheDocument();
expect(screen.getByText("access policies")).toBeInTheDocument();
expect(screen.getByText("are rotated…")).toBeInTheDocument();
expect(screen.getByText("high")).toBeInTheDocument();
await userEvent.hover(screen.getByTestId("attack-path-finding-node"));
expect(await screen.findAllByText(title)).not.toHaveLength(0);
});
});
});
@@ -2,21 +2,27 @@
import { type NodeProps, Position } from "@xyflow/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const NODE_WIDTH = 150;
const NODE_HEIGHT = 112;
const TITLE_MAX_CHARS = 18;
const TITLE_MAX_LINES = 2;
const NODE_WIDTH = FINDING_NODE_DIMENSIONS.WIDTH;
const NODE_HEIGHT = FINDING_NODE_DIMENSIONS.HEIGHT;
const TITLE_MAX_CHARS = FINDING_NODE_DIMENSIONS.LABEL_MAX_CHARS;
const TITLE_MAX_LINES = FINDING_NODE_DIMENSIONS.LABEL_MAX_LINES;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
@@ -29,7 +35,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const TITLE_Y = 66;
const TITLE_LINE_HEIGHT = 13;
const SEVERITY_Y = 94;
const SEVERITY_Y = 118;
const severityLabel = (severity: unknown): string | undefined => {
if (!severity) return undefined;
@@ -59,7 +65,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
graphNode.properties?.id ||
"Finding",
);
const displayTitleLines = getNodeLabelLines(
const displayTitle = getNodeLabelDisplay(
title,
TITLE_MAX_CHARS,
TITLE_MAX_LINES,
@@ -72,6 +78,85 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
const badgeStrokeWidth = selected ? 4 : 2.5;
const glowRadius = selected ? 32 : 30;
const glowOpacity = selected ? 0.34 : 0.28;
const nodeSvg = (
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
className="overflow-visible"
tabIndex={displayTitle.isTruncated ? 0 : undefined}
data-testid="attack-path-finding-node"
>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitle.lines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
);
return (
<>
@@ -81,77 +166,14 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitleLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
{displayTitle.isTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{nodeSvg}</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
) : (
nodeSvg
)}
</>
);
};
@@ -1,48 +0,0 @@
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
if (!currentLine) {
currentLine = word;
continue;
}
const nextLine = `${currentLine} ${word}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = word;
}
if (currentLine) lines.push(currentLine);
return lines;
};
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
export const getNodeLabelLines = (
text: string,
maxChars: number,
maxLines: number,
): string[] => {
if (!text.trim()) return [];
const rawLines = text.includes(" ")
? splitByMaxChars(text, maxChars)
: splitLongToken(text, maxChars);
return rawLines.slice(0, maxLines);
};
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
@@ -84,5 +85,41 @@ describe("ResourceNode", () => {
expect(screen.getByText("main-vpc")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
});
it("should show up to four readable lines for long resource names", () => {
// Given
const props = buildNodeProps(
buildGraphNode("AWSRole", "AWSReservedSSO_AdministratorAccessExtra"),
);
// When
const { container } = render(<ResourceNode {...props} />);
// Then
expect(screen.getByText("AWSReservedSSO_A")).toBeInTheDocument();
expect(screen.getByText("dministratorAcce")).toBeInTheDocument();
expect(screen.getByText("ssExtra")).toBeInTheDocument();
expect(screen.getByText("AWS Role")).toBeInTheDocument();
expect(container.querySelector("title")).toBeNull();
});
it("should expose the full resource name as an immediate tooltip when truncated", async () => {
// Given
const name =
"arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration";
const props = buildNodeProps(buildGraphNode("AWSRole", name));
// When
render(<ResourceNode {...props} />);
// Then
expect(screen.getByText("arn:aws:iam::998")).toBeInTheDocument();
expect(screen.getByText("057895221:role/O")).toBeInTheDocument();
expect(screen.getByText("ntAccessRole/in…")).toBeInTheDocument();
await userEvent.hover(screen.getByTestId("attack-path-resource-node"));
expect(await screen.findAllByText(name)).not.toHaveLength(0);
});
});
});
@@ -2,11 +2,17 @@
import { type NodeProps, Position } from "@xyflow/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface ResourceNodeData {
graphNode: GraphNode;
@@ -14,10 +20,10 @@ interface ResourceNodeData {
[key: string]: unknown;
}
const NODE_WIDTH = 136;
const NODE_HEIGHT = 112;
const NAME_MAX_CHARS = 16;
const NAME_MAX_LINES = 2;
const NODE_WIDTH = RESOURCE_NODE_DIMENSIONS.WIDTH;
const NODE_HEIGHT = RESOURCE_NODE_DIMENSIONS.HEIGHT;
const NAME_MAX_CHARS = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_CHARS;
const NAME_MAX_LINES = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_LINES;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
@@ -30,7 +36,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const NAME_Y = 66;
const NAME_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const TYPE_Y = 118;
const toIconTestId = (description: string): string =>
`attack-path-node-icon-${description
@@ -52,13 +58,90 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const displayNameLines = getNodeLabelLines(
const displayName = getNodeLabelDisplay(
visual.displayName,
NAME_MAX_CHARS,
NAME_MAX_LINES,
);
const typeLabel = visual.description;
const iconLabel = `${visual.description} icon`;
const nodeSvg = (
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
className="overflow-visible"
tabIndex={displayName.isTruncated ? 0 : undefined}
data-testid="attack-path-resource-node"
>
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayName.lines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
);
return (
<>
@@ -68,75 +151,14 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayNameLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
{displayName.isTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{nodeSvg}</TooltipTrigger>
<TooltipContent>{visual.displayName}</TooltipContent>
</Tooltip>
) : (
nodeSvg
)}
</>
);
};
@@ -126,6 +126,50 @@ describe("exportGraphAsPNG", () => {
expect(link?.href).toBe("data:image/png;base64,AAAA");
});
it("renders exported long resource labels with the same wrapping as graph nodes", async () => {
const container = buildContainerWithViewport();
const longLabelGraphData: AttackPathGraphData = {
nodes: [
{
id: "role-1",
labels: ["AWSRole"],
properties: { name: "AWSReservedSSO_AdministratorAccessExtra" },
},
],
};
await exportGraphAsPNG(container, bounds, "graph.png", longLabelGraphData);
const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock
.results[0]?.value as CanvasRenderingContext2D;
const fillText = vi.mocked(context.fillText);
expect(fillText).toHaveBeenCalledWith(
"AWSReservedSSO_A",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"dministratorAcce",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"ssExtra",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"AWS Role",
expect.any(Number),
expect.any(Number),
136,
);
});
it("re-throws a generic export error when canvas is unavailable", async () => {
const container = buildContainerWithViewport();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
@@ -17,6 +17,11 @@ import {
GRAPH_EDGE_COLOR_DARK,
} from "./graph-colors";
import { layoutWithDagre } from "./layout";
import {
FINDING_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -40,9 +45,7 @@ const BADGE_CENTER_Y = 26;
const GLOW_RADIUS = 30;
const LABEL_Y = 66;
const LABEL_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const RESOURCE_NAME_MAX_CHARS = 18;
const RESOURCE_NAME_MAX_LINES = 2;
const TYPE_Y = 118;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
@@ -165,33 +168,6 @@ const getResourcesWithFindings = (
return resourcesWithFindings;
};
const getLabelLines = (label: string, maxChars: number, maxLines: number) => {
const words = label.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length <= maxChars) {
current = next;
return;
}
if (current) lines.push(current);
current = word;
});
if (current) lines.push(current);
if (lines.length === 0) lines.push(label);
const visibleLines = lines.slice(0, maxLines);
if (lines.length > maxLines && visibleLines.length > 0) {
const lastIndex = visibleLines.length - 1;
visibleLines[lastIndex] = truncateLabel(visibleLines[lastIndex], maxChars);
}
return visibleLines;
};
const getFittedLayout = (graphData: AttackPathGraphData) => {
const { rfNodes, rfEdges } = layoutWithDagre(
graphData.nodes,
@@ -464,16 +440,21 @@ const drawNode = (
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "600 11px sans-serif";
getLabelLines(
const dimensions = isFinding
? FINDING_NODE_DIMENSIONS
: RESOURCE_NODE_DIMENSIONS;
const labelMaxWidth = dimensions.WIDTH;
getNodeLabelDisplay(
visual.displayName,
RESOURCE_NAME_MAX_CHARS,
RESOURCE_NAME_MAX_LINES,
).forEach((line, index) => {
dimensions.LABEL_MAX_CHARS,
dimensions.LABEL_MAX_LINES,
).lines.forEach((line, index) => {
context.fillText(
line,
center.x,
center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT,
150,
labelMaxWidth,
);
});
@@ -483,7 +464,7 @@ const drawNode = (
typeLabel,
center.x,
center.y + (TYPE_Y - BADGE_CENTER_Y),
150,
labelMaxWidth,
);
};
@@ -40,12 +40,12 @@ describe("layoutWithDagre", () => {
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 150,
height: 112,
height: 124,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 136,
height: 112,
height: 124,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
@@ -8,12 +8,11 @@ import { type Edge, type Node, Position } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the rendered React Flow custom nodes.
const RESOURCE_NODE_WIDTH = 136;
const RESOURCE_NODE_HEIGHT = 112;
const FINDING_NODE_WIDTH = 150;
const FINDING_NODE_HEIGHT = 112;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
import {
FINDING_NODE_DIMENSIONS,
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -49,10 +48,19 @@ const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT };
return {
width: FINDING_NODE_DIMENSIONS.WIDTH,
height: FINDING_NODE_DIMENSIONS.HEIGHT,
};
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT };
return {
width: INTERNET_NODE_DIMENSIONS.DIAMETER,
height: INTERNET_NODE_DIMENSIONS.DIAMETER,
};
return {
width: RESOURCE_NODE_DIMENSIONS.WIDTH,
height: RESOURCE_NODE_DIMENSIONS.HEIGHT,
};
};
/**
@@ -0,0 +1,17 @@
export const RESOURCE_NODE_DIMENSIONS = {
WIDTH: 136,
HEIGHT: 124,
LABEL_MAX_CHARS: 16,
LABEL_MAX_LINES: 4,
} as const;
export const FINDING_NODE_DIMENSIONS = {
WIDTH: 150,
HEIGHT: 124,
LABEL_MAX_CHARS: 18,
LABEL_MAX_LINES: 4,
} as const;
export const INTERNET_NODE_DIMENSIONS = {
DIAMETER: 80,
} as const;
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getNodeLabelDisplay } from "./node-label-lines";
describe("getNodeLabelDisplay", () => {
it("adds an ellipsis within the max width when wrapped label text exceeds the visible line budget", () => {
expect(
getNodeLabelDisplay("AWSReservedSSO_AdministratorAccess", 16, 2).lines,
).toEqual(["AWSReservedSSO_A", "dministratorAcc…"]);
});
it("splits long tokens so unbroken identifiers do not overflow node labels", () => {
expect(
getNodeLabelDisplay("OrganizationAccountAccessRole", 16, 4).lines,
).toEqual(["OrganizationAcco", "untAccessRole"]);
});
it("reports whether the visible label was truncated", () => {
expect(getNodeLabelDisplay("short-name", 16, 4)).toMatchObject({
isTruncated: false,
lines: ["short-name"],
});
expect(
getNodeLabelDisplay(
"arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration",
16,
4,
),
).toMatchObject({ isTruncated: true });
});
});
@@ -0,0 +1,64 @@
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
const wordLines = splitLongToken(word, maxChars);
for (const wordLine of wordLines) {
if (!currentLine) {
currentLine = wordLine;
continue;
}
const nextLine = `${currentLine} ${wordLine}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = wordLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
};
const withEllipsis = (line: string, maxChars: number): string => {
if (maxChars <= 1) return "…";
return `${line.slice(0, maxChars - 1)}`;
};
export const getNodeLabelDisplay = (
text: string,
maxChars: number,
maxLines: number,
): { lines: string[]; isTruncated: boolean } => {
if (!text.trim()) return { lines: [], isTruncated: false };
const rawLines = splitByMaxChars(text, maxChars);
const isTruncated = rawLines.length > maxLines;
const visibleLines = rawLines.slice(0, maxLines);
if (isTruncated && visibleLines.length > 0) {
visibleLines[visibleLines.length - 1] = withEllipsis(
visibleLines[visibleLines.length - 1],
maxChars,
);
}
return { lines: visibleLines, isTruncated };
};