diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index fc2359a655..173e4cd044 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -21,6 +21,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
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx
index 74a1de7e5d..d29a262ee2 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx
@@ -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();
+
+ // 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);
+ });
});
});
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
index 986617def7..789a379551 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
@@ -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 = (
+
+ );
return (
<>
@@ -81,77 +166,14 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
-
+ {displayTitle.isTruncated ? (
+
+ {nodeSvg}
+ {title}
+
+ ) : (
+ nodeSvg
+ )}
>
);
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts
deleted file mode 100644
index a6ed74e70a..0000000000
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts
+++ /dev/null
@@ -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);
-};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx
index 937e6cb2ac..4cdf1a46b3 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx
@@ -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();
+
+ // 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();
+
+ // 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);
+ });
});
});
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
index 324d3aa4b9..9860dbea27 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
@@ -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 = (
+
+ );
return (
<>
@@ -68,75 +151,14 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
-
+ {displayName.isTruncated ? (
+
+ {nodeSvg}
+ {visual.displayName}
+
+ ) : (
+ nodeSvg
+ )}
>
);
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts
index 5f4131e695..0e1e3ff2f3 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts
@@ -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);
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
index 71926ad8c3..a10e0d474e 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
@@ -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,
);
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
index eb972c37be..7d8cb20c96 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
@@ -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",
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
index d3df3dfd46..cb67d11083 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
@@ -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,
+ };
};
/**
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts
new file mode 100644
index 0000000000..e6dea8af9b
--- /dev/null
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts
@@ -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;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts
new file mode 100644
index 0000000000..89c8060d57
--- /dev/null
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts
@@ -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 });
+ });
+});
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts
new file mode 100644
index 0000000000..3bceddfe4c
--- /dev/null
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts
@@ -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 };
+};