mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-18 18:22:46 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f432f8f79 | |||
| 0d25c1f369 | |||
| e204cc4063 | |||
| cddf1de6f8 |
@@ -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
|
||||
|
||||
|
||||
+22
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+100
-78
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
-48
@@ -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);
|
||||
};
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
-76
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user