From 31b9619627d5d1296afc9f2874a4dea3dcbe9829 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Tue, 19 May 2026 09:32:16 +0200 Subject: [PATCH] fix(ui): improve attack paths node labels (#11197) --- ui/CHANGELOG.md | 1 + .../graph/nodes/finding-node.test.tsx | 22 +++ .../_components/graph/nodes/finding-node.tsx | 178 ++++++++++-------- .../graph/nodes/node-label-lines.ts | 48 ----- .../graph/nodes/resource-node.test.tsx | 37 ++++ .../_components/graph/nodes/resource-node.tsx | 174 +++++++++-------- .../query-builder/_lib/export.test.ts | 44 +++++ .../(workflow)/query-builder/_lib/export.ts | 53 ++---- .../query-builder/_lib/layout.test.ts | 4 +- .../(workflow)/query-builder/_lib/layout.ts | 26 ++- .../query-builder/_lib/node-dimensions.ts | 17 ++ .../_lib/node-label-lines.test.ts | 31 +++ .../query-builder/_lib/node-label-lines.ts | 64 +++++++ 13 files changed, 450 insertions(+), 249 deletions(-) delete mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts 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 = ( + + + + + + + + {displayTitle.lines.map((line, index) => ( + + {line} + + ))} + {severity && ( + + {severity} + + )} + + + ); return ( <> @@ -81,77 +166,14 @@ export const FindingNode = ({ data, selected }: NodeProps) => { targetPosition={Position.Left} targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }} /> - - - - - - - - {displayTitleLines.map((line, index) => ( - - {line} - - ))} - {severity && ( - - {severity} - - )} - - + {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 = ( + + {glowRadius > 0 && ( + + )} + + + + + + {displayName.lines.map((line, index) => ( + + {line} + + ))} + {typeLabel && ( + + {typeLabel} + + )} + + + ); return ( <> @@ -68,75 +151,14 @@ export const ResourceNode = ({ data, selected }: NodeProps) => { targetPosition={Position.Left} targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }} /> - - {glowRadius > 0 && ( - - )} - - - - - - {displayNameLines.map((line, index) => ( - - {line} - - ))} - {typeLabel && ( - - {typeLabel} - - )} - - + {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 }; +};