mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
feat(ui): improve attack paths graph exploration
This commit is contained in:
+61
-47
@@ -206,15 +206,8 @@ const GraphCanvas = ({
|
||||
onInitialFilter,
|
||||
ref,
|
||||
}: GraphCanvasProps) => {
|
||||
const {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fitView,
|
||||
getZoom,
|
||||
getNodes,
|
||||
getNodesBounds,
|
||||
getViewport,
|
||||
} = useReactFlow();
|
||||
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
|
||||
useReactFlow();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hasInitialized = useRef(false);
|
||||
@@ -264,16 +257,11 @@ const GraphCanvas = ({
|
||||
// pointing at coordinates that no longer contain the new layout. Re-fit
|
||||
// once React Flow has applied the new layout (next animation frame).
|
||||
//
|
||||
// Resource expansion fits ONLY when a newly revealed finding sits
|
||||
// entirely outside the current viewport (i.e. its full bounding box
|
||||
// is past one of the viewport edges). This intentionally lets
|
||||
// partially-clipped edge nodes through without re-fitting — hidden
|
||||
// findings contribute zero size to the initial bbox, so a finding's
|
||||
// far edge often peeks past the padded viewport on the first reveal,
|
||||
// and a "partially outside" check would re-fit on every expand. The
|
||||
// user's "no cabe visualmente" complaint is about findings that
|
||||
// appear completely off-screen after the user has panned away, which
|
||||
// the strict check captures.
|
||||
// Resource expansion re-fits after newly revealed finding nodes have been
|
||||
// measured. This restores the original Show findings behavior: expanding a
|
||||
// resource should frame the selected resource plus its findings, not the
|
||||
// entire graph. Collapsing re-fits the remaining visible graph so the user
|
||||
// does not stay zoomed into empty space after hiding finding children.
|
||||
const filteredFitInitRef = useRef(false);
|
||||
const previousFilteredRef = useRef(isFilteredView);
|
||||
const previousExpandedRef = useRef<ReadonlySet<string>>(expanded);
|
||||
@@ -290,6 +278,7 @@ const GraphCanvas = ({
|
||||
return;
|
||||
}
|
||||
if (previousFilteredRef.current === isFilteredView) return;
|
||||
const wasFilteredView = previousFilteredRef.current;
|
||||
previousFilteredRef.current = isFilteredView;
|
||||
// React Flow measures node sizes asynchronously via ResizeObserver after
|
||||
// the data swap. A single rAF runs while measured.width is still 0, so
|
||||
@@ -305,9 +294,30 @@ const GraphCanvas = ({
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||||
);
|
||||
},
|
||||
() => fitView(AUTO_FIT_OPTIONS),
|
||||
() => {
|
||||
if (wasFilteredView && !isFilteredView && expanded.size > 0) {
|
||||
const contextualNodeIds = new Set<string>();
|
||||
for (const resourceId of Array.from(expanded)) {
|
||||
contextualNodeIds.add(resourceId);
|
||||
resourceToFindingsRef.current
|
||||
.get(resourceId)
|
||||
?.forEach((findingId) => contextualNodeIds.add(findingId));
|
||||
}
|
||||
|
||||
const contextualNodes = getNodes().filter(
|
||||
(node) => !node.hidden && contextualNodeIds.has(node.id),
|
||||
);
|
||||
|
||||
if (contextualNodes.length > 0) {
|
||||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fitView(AUTO_FIT_OPTIONS);
|
||||
},
|
||||
);
|
||||
}, [isFilteredView, fitView, getNodes]);
|
||||
}, [expanded, isFilteredView, fitView, getNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousExpandedRef.current;
|
||||
@@ -316,22 +326,40 @@ const GraphCanvas = ({
|
||||
const newResourceIds = Array.from(expanded).filter(
|
||||
(id) => !previous.has(id),
|
||||
);
|
||||
// Only fit on growth — collapsing intentionally leaves the user's
|
||||
// current framing alone.
|
||||
if (newResourceIds.length === 0) return;
|
||||
const collapsedResourceIds = Array.from(previous).filter(
|
||||
(id) => !expanded.has(id),
|
||||
);
|
||||
|
||||
if (newResourceIds.length === 0) {
|
||||
if (collapsedResourceIds.length === 0) return;
|
||||
|
||||
return scheduleMeasuredFit(
|
||||
() => {
|
||||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||||
return (
|
||||
visibleNodes.length > 0 &&
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||||
);
|
||||
},
|
||||
() => fitView(AUTO_FIT_OPTIONS),
|
||||
);
|
||||
}
|
||||
|
||||
const contextualFitNodeIds = new Set<string>(newResourceIds);
|
||||
const newFindingIds = new Set<string>();
|
||||
for (const resourceId of newResourceIds) {
|
||||
const findings = resourceToFindingsRef.current.get(resourceId);
|
||||
if (!findings) continue;
|
||||
findings.forEach((id) => newFindingIds.add(id));
|
||||
findings.forEach((id) => {
|
||||
newFindingIds.add(id);
|
||||
contextualFitNodeIds.add(id);
|
||||
});
|
||||
}
|
||||
if (newFindingIds.size === 0) return;
|
||||
|
||||
// Findings transition from hidden to visible on expand, and React Flow
|
||||
// measures them asynchronously. Poll before checking whether their full
|
||||
// bounding boxes sit entirely past a viewport edge; collapsing and
|
||||
// partially clipped findings preserve the user's current frame.
|
||||
// measures them asynchronously. Poll before fitting so fitView uses real
|
||||
// dimensions instead of the zero-size hidden-node measurements.
|
||||
return scheduleMeasuredFit(
|
||||
() => {
|
||||
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
|
||||
@@ -341,27 +369,13 @@ const GraphCanvas = ({
|
||||
);
|
||||
},
|
||||
() => {
|
||||
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
const { width, height } = containerEl.getBoundingClientRect();
|
||||
if (width === 0 || height === 0) return;
|
||||
const { x, y, zoom } = getViewport();
|
||||
const minX = -x / zoom;
|
||||
const minY = -y / zoom;
|
||||
const maxX = minX + width / zoom;
|
||||
const maxY = minY + height / zoom;
|
||||
const anyOutside = targets.some((node) => {
|
||||
const nx = node.position.x;
|
||||
const ny = node.position.y;
|
||||
const nw = node.measured?.width ?? 0;
|
||||
const nh = node.measured?.height ?? 0;
|
||||
return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY;
|
||||
});
|
||||
if (anyOutside) fitView(AUTO_FIT_OPTIONS);
|
||||
const contextualNodes = getNodes().filter((n) =>
|
||||
contextualFitNodeIds.has(n.id),
|
||||
);
|
||||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||||
},
|
||||
);
|
||||
}, [expanded, fitView, getNodes, getViewport]);
|
||||
}, [expanded, fitView, getNodes]);
|
||||
|
||||
const nodes = effectiveData.nodes ?? [];
|
||||
const edges = effectiveData.edges ?? [];
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AttackPathGraphData } from "@/types/attack-paths";
|
||||
|
||||
import { GraphLegend } from "./graph-legend";
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ resolvedTheme: "dark" }),
|
||||
}));
|
||||
|
||||
const graphData: AttackPathGraphData = {
|
||||
nodes: [
|
||||
{
|
||||
id: "aws-account",
|
||||
labels: ["AWSAccount"],
|
||||
properties: { name: "Production" },
|
||||
},
|
||||
{ id: "bucket", labels: ["S3Bucket"], properties: { name: "logs" } },
|
||||
{ id: "vpc", labels: ["VPC"], properties: { name: "main" } },
|
||||
{
|
||||
id: "finding",
|
||||
labels: ["ProwlerFinding"],
|
||||
properties: { check_title: "Public bucket", severity: "critical" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("GraphLegend", () => {
|
||||
it("should explain graph visuals by semantic groups instead of repeating node labels", () => {
|
||||
// Given - A graph with provider, resource, and finding nodes
|
||||
|
||||
// When
|
||||
render(<GraphLegend data={graphData} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /provider roots/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /resource categories/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /findings by risk/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /states/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /edges/i })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Provider / account root")).toBeInTheDocument();
|
||||
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Network")).toBeInTheDocument();
|
||||
expect(screen.getByText("Compute")).toBeInTheDocument();
|
||||
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secret / misc")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Critical")).toBeInTheDocument();
|
||||
expect(screen.getByText("High")).toBeInTheDocument();
|
||||
expect(screen.getByText("Medium")).toBeInTheDocument();
|
||||
expect(screen.getByText("Low / Info")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Selected node")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node with findings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Normal edge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Finding edge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Highlighted path")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("S3 Bucket")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("VPC")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should stay hidden until graph nodes are available", () => {
|
||||
// Given - No graph nodes have been loaded yet
|
||||
const emptyGraphData: AttackPathGraphData = { nodes: [] };
|
||||
|
||||
// When
|
||||
const { container } = render(<GraphLegend data={emptyGraphData} />);
|
||||
|
||||
// Then
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: /provider roots/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+384
-471
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import {
|
||||
@@ -9,513 +10,425 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import type { AttackPathGraphData } from "@/types/attack-paths";
|
||||
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
getNodeBorderColor,
|
||||
getNodeColor,
|
||||
GRAPH_ALERT_BORDER_COLOR,
|
||||
GRAPH_EDGE_COLOR_DARK,
|
||||
GRAPH_EDGE_COLOR_LIGHT,
|
||||
GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
GRAPH_NODE_BORDER_COLORS,
|
||||
GRAPH_NODE_COLORS,
|
||||
} from "../../_lib/graph-colors";
|
||||
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
|
||||
|
||||
interface LegendItem {
|
||||
const LEGEND_PREVIEW = {
|
||||
BADGE_RADIUS: 16,
|
||||
BADGE_CENTER: 18,
|
||||
ICON_SIZE: 20,
|
||||
ICON_OFFSET: 8,
|
||||
SVG_SIZE: 36,
|
||||
} as const;
|
||||
|
||||
const EDGE_VARIANT = {
|
||||
NORMAL: "normal",
|
||||
FINDING: "finding",
|
||||
HIGHLIGHTED: "highlighted",
|
||||
} as const;
|
||||
|
||||
type EdgeVariant = (typeof EDGE_VARIANT)[keyof typeof EDGE_VARIANT];
|
||||
|
||||
interface LegendVisualItem {
|
||||
label: string;
|
||||
color: string;
|
||||
borderColor: string;
|
||||
description: string;
|
||||
shape: "rectangle" | "hexagon" | "cloud";
|
||||
Icon: ElementType;
|
||||
fillColor: string;
|
||||
borderColor: string;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
// Map node labels to human-readable names and descriptions
|
||||
const nodeTypeDescriptions: Record<
|
||||
string,
|
||||
{ name: string; description: string }
|
||||
> = {
|
||||
// Findings
|
||||
ProwlerFinding: {
|
||||
name: "Finding",
|
||||
description: "Security findings from Prowler scans",
|
||||
},
|
||||
// AWS Account
|
||||
AWSAccount: {
|
||||
name: "AWS Account",
|
||||
description: "AWS account root node",
|
||||
},
|
||||
// Compute
|
||||
EC2Instance: {
|
||||
name: "EC2 Instance",
|
||||
description: "Elastic Compute Cloud instance",
|
||||
},
|
||||
LambdaFunction: {
|
||||
name: "Lambda Function",
|
||||
description: "AWS Lambda serverless function",
|
||||
},
|
||||
// Storage
|
||||
S3Bucket: {
|
||||
name: "S3 Bucket",
|
||||
description: "Simple Storage Service bucket",
|
||||
},
|
||||
// IAM
|
||||
IAMRole: {
|
||||
name: "IAM Role",
|
||||
description: "Identity and Access Management role",
|
||||
},
|
||||
IAMPolicy: {
|
||||
name: "IAM Policy",
|
||||
description: "Identity and Access Management policy",
|
||||
},
|
||||
AWSRole: {
|
||||
name: "AWS Role",
|
||||
description: "AWS IAM role",
|
||||
},
|
||||
AWSPolicy: {
|
||||
name: "AWS Policy",
|
||||
description: "AWS IAM policy",
|
||||
},
|
||||
AWSInlinePolicy: {
|
||||
name: "AWS Inline Policy",
|
||||
description: "AWS IAM inline policy",
|
||||
},
|
||||
AWSPolicyStatement: {
|
||||
name: "AWS Policy Statement",
|
||||
description: "AWS IAM policy statement",
|
||||
},
|
||||
AWSPrincipal: {
|
||||
name: "AWS Principal",
|
||||
description: "AWS IAM principal entity",
|
||||
},
|
||||
// Networking
|
||||
SecurityGroup: {
|
||||
name: "Security Group",
|
||||
description: "AWS security group for network access control",
|
||||
},
|
||||
EC2SecurityGroup: {
|
||||
name: "EC2 Security Group",
|
||||
description: "EC2 security group for network access control",
|
||||
},
|
||||
IpPermissionInbound: {
|
||||
name: "IP Permission Inbound",
|
||||
description: "Inbound IP permission rule",
|
||||
},
|
||||
IpRule: {
|
||||
name: "IP Rule",
|
||||
description: "IP address rule",
|
||||
},
|
||||
Internet: {
|
||||
name: "Internet",
|
||||
description: "Internet gateway or public access",
|
||||
},
|
||||
// Tags
|
||||
AWSTag: {
|
||||
name: "AWS Tag",
|
||||
description: "AWS resource tag",
|
||||
},
|
||||
Tag: {
|
||||
name: "Tag",
|
||||
description: "Resource tag",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract unique node types from graph data
|
||||
*/
|
||||
function extractNodeTypes(
|
||||
nodes: AttackPathGraphData["nodes"] | undefined,
|
||||
): string[] {
|
||||
if (!nodes) return [];
|
||||
|
||||
const nodeTypes = new Set<string>();
|
||||
nodes.forEach((node) => {
|
||||
node.labels.forEach((label) => {
|
||||
nodeTypes.add(label);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(nodeTypes).sort();
|
||||
interface LegendStateItem {
|
||||
label: string;
|
||||
description: string;
|
||||
fillColor: string;
|
||||
borderColor: string;
|
||||
strokeWidth: number;
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity legend items - colors work in both light and dark themes
|
||||
*/
|
||||
const severityLegendItems: LegendItem[] = [
|
||||
{
|
||||
label: "Critical",
|
||||
color: GRAPH_NODE_COLORS.critical,
|
||||
borderColor: GRAPH_NODE_BORDER_COLORS.critical,
|
||||
description: "Critical severity finding",
|
||||
shape: "hexagon",
|
||||
},
|
||||
{
|
||||
label: "High",
|
||||
color: GRAPH_NODE_COLORS.high,
|
||||
borderColor: GRAPH_NODE_BORDER_COLORS.high,
|
||||
description: "High severity finding",
|
||||
shape: "hexagon",
|
||||
},
|
||||
{
|
||||
label: "Medium",
|
||||
color: GRAPH_NODE_COLORS.medium,
|
||||
borderColor: GRAPH_NODE_BORDER_COLORS.medium,
|
||||
description: "Medium severity finding",
|
||||
shape: "hexagon",
|
||||
},
|
||||
{
|
||||
label: "Low",
|
||||
color: GRAPH_NODE_COLORS.low,
|
||||
borderColor: GRAPH_NODE_BORDER_COLORS.low,
|
||||
description: "Low severity finding",
|
||||
shape: "hexagon",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate legend items from graph data
|
||||
*/
|
||||
function generateLegendItems(
|
||||
nodeTypes: string[],
|
||||
hasFindings: boolean,
|
||||
): LegendItem[] {
|
||||
const items: LegendItem[] = [];
|
||||
const seenTypes = new Set<string>();
|
||||
|
||||
// Add severity items if there are findings
|
||||
if (hasFindings) {
|
||||
items.push(...severityLegendItems);
|
||||
}
|
||||
|
||||
// Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement")
|
||||
const formatNodeTypeName = (nodeType: string): string => {
|
||||
return nodeType
|
||||
.replace(/([A-Z])/g, " $1") // Add space before capitals
|
||||
.replace(/^ /, "") // Remove leading space
|
||||
.replace(/AWS /g, "AWS ") // Keep AWS together
|
||||
.replace(/EC2 /g, "EC2 ") // Keep EC2 together
|
||||
.replace(/S3 /g, "S3 ") // Keep S3 together
|
||||
.replace(/IAM /g, "IAM ") // Keep IAM together
|
||||
.replace(/IP /g, "IP ") // Keep IP together
|
||||
.trim();
|
||||
};
|
||||
|
||||
nodeTypes.forEach((nodeType) => {
|
||||
if (seenTypes.has(nodeType)) return;
|
||||
seenTypes.add(nodeType);
|
||||
|
||||
// Skip findings - we show severity colors instead
|
||||
const isFinding = nodeType.toLowerCase().includes("finding");
|
||||
if (isFinding) return;
|
||||
|
||||
const description = nodeTypeDescriptions[nodeType];
|
||||
|
||||
// Determine shape based on node type
|
||||
const isInternet = nodeType.toLowerCase() === "internet";
|
||||
const shape: "rectangle" | "hexagon" | "cloud" = isInternet
|
||||
? "cloud"
|
||||
: "rectangle";
|
||||
|
||||
if (description) {
|
||||
items.push({
|
||||
label: description.name,
|
||||
color: getNodeColor([nodeType]),
|
||||
borderColor: getNodeBorderColor([nodeType]),
|
||||
description: description.description,
|
||||
shape,
|
||||
});
|
||||
} else {
|
||||
// Format unknown node types nicely
|
||||
const formattedName = formatNodeTypeName(nodeType);
|
||||
items.push({
|
||||
label: formattedName,
|
||||
color: getNodeColor([nodeType]),
|
||||
borderColor: getNodeBorderColor([nodeType]),
|
||||
description: `${formattedName} node`,
|
||||
shape,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
interface LegendEdgeItem {
|
||||
label: string;
|
||||
description: string;
|
||||
variant: EdgeVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hexagon shape component for legend
|
||||
*/
|
||||
const HexagonShape = ({
|
||||
color,
|
||||
borderColor,
|
||||
}: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
}) => (
|
||||
<svg width="32" height="22" viewBox="0 0 32 22" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="legendGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M5 1 L27 1 L31 11 L27 21 L5 21 L1 11 Z"
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={1.5}
|
||||
filter="url(#legendGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
interface LegendSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pill shape component for legend
|
||||
*/
|
||||
const PillShape = ({
|
||||
color,
|
||||
borderColor,
|
||||
}: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
}) => (
|
||||
<svg width="36" height="20" viewBox="0 0 36 20" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="legendGlow2" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="34"
|
||||
height="18"
|
||||
rx="9"
|
||||
ry="9"
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={1.5}
|
||||
filter="url(#legendGlow2)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Globe shape component for legend (used for Internet nodes)
|
||||
*/
|
||||
const GlobeShape = ({
|
||||
color,
|
||||
borderColor,
|
||||
}: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
}) => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="legendGlow3" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Globe circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={1.5}
|
||||
filter="url(#legendGlow3)"
|
||||
/>
|
||||
{/* Horizontal line */}
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="12"
|
||||
rx="10"
|
||||
ry="4"
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
{/* Vertical ellipse */}
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="12"
|
||||
rx="4"
|
||||
ry="10"
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Edge line component for legend
|
||||
*/
|
||||
const EdgeLine = ({
|
||||
dashed,
|
||||
edgeColor,
|
||||
}: {
|
||||
dashed: boolean;
|
||||
edgeColor: string;
|
||||
}) => (
|
||||
<svg
|
||||
width="60"
|
||||
height="20"
|
||||
viewBox="0 0 60 20"
|
||||
aria-hidden="true"
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
{/* Line */}
|
||||
<line
|
||||
x1="4"
|
||||
y1="10"
|
||||
x2="44"
|
||||
y2="10"
|
||||
stroke={edgeColor}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={dashed ? "8,6" : undefined}
|
||||
/>
|
||||
{/* Arrow head */}
|
||||
<polygon points="44,5 56,10 44,15" fill={edgeColor} />
|
||||
</svg>
|
||||
);
|
||||
interface LegendItemProps {
|
||||
label: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface GraphLegendProps {
|
||||
data?: AttackPathGraphData;
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
labels: string[],
|
||||
properties: GraphNode["properties"] = {},
|
||||
): GraphNode => ({
|
||||
id: labels[0] ?? "legend-node",
|
||||
labels,
|
||||
properties,
|
||||
});
|
||||
|
||||
const buildVisualItem = (
|
||||
label: string,
|
||||
description: string,
|
||||
node: GraphNode,
|
||||
fillColor: string,
|
||||
borderColor: string,
|
||||
glow = false,
|
||||
): LegendVisualItem => ({
|
||||
label,
|
||||
description,
|
||||
Icon: resolveNodeVisual(node).Icon,
|
||||
fillColor,
|
||||
borderColor,
|
||||
glow,
|
||||
});
|
||||
|
||||
const providerRootItem = buildVisualItem(
|
||||
"Provider / account root",
|
||||
"Cloud account, tenant, project, organization, or cluster entry point.",
|
||||
buildNode(["AWSAccount"], { name: "Provider root" }),
|
||||
GRAPH_NODE_COLORS.awsAccount,
|
||||
GRAPH_NODE_BORDER_COLORS.awsAccount,
|
||||
);
|
||||
|
||||
const resourceCategoryItems: LegendVisualItem[] = [
|
||||
buildVisualItem(
|
||||
"Storage",
|
||||
"Data stores such as buckets and object storage.",
|
||||
buildNode(["S3Bucket"], { name: NODE_CATEGORY.STORAGE }),
|
||||
GRAPH_NODE_COLORS.s3Bucket,
|
||||
GRAPH_NODE_BORDER_COLORS.s3Bucket,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Network",
|
||||
"VPCs, security boundaries, gateways, and reachable network resources.",
|
||||
buildNode(["VPC"], { name: NODE_CATEGORY.NETWORK }),
|
||||
GRAPH_NODE_COLORS.securityGroup,
|
||||
GRAPH_NODE_BORDER_COLORS.securityGroup,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Compute",
|
||||
"Instances, virtual machines, functions, and execution surfaces.",
|
||||
buildNode(["EC2Instance"], { name: NODE_CATEGORY.COMPUTE }),
|
||||
GRAPH_NODE_COLORS.ec2Instance,
|
||||
GRAPH_NODE_BORDER_COLORS.ec2Instance,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Identity",
|
||||
"Users, roles, policies, and permission-bearing principals.",
|
||||
buildNode(["IAMRole"], { name: NODE_CATEGORY.IDENTITY }),
|
||||
GRAPH_NODE_COLORS.iamRole,
|
||||
GRAPH_NODE_BORDER_COLORS.iamRole,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Secret / misc",
|
||||
"Secrets, access keys, or fallback resources that do not map to a known category.",
|
||||
buildNode(["AccessKey"], { name: NODE_CATEGORY.SECRET }),
|
||||
GRAPH_NODE_COLORS.default,
|
||||
GRAPH_NODE_BORDER_COLORS.default,
|
||||
),
|
||||
];
|
||||
|
||||
const findingRiskItems: LegendVisualItem[] = [
|
||||
buildVisualItem(
|
||||
"Critical",
|
||||
"Highest-risk finding node with severity-colored badge and glow.",
|
||||
buildNode(["ProwlerFinding"], { severity: "critical" }),
|
||||
GRAPH_NODE_COLORS.critical,
|
||||
GRAPH_NODE_BORDER_COLORS.critical,
|
||||
true,
|
||||
),
|
||||
buildVisualItem(
|
||||
"High",
|
||||
"High-risk finding node with severity-colored badge and glow.",
|
||||
buildNode(["ProwlerFinding"], { severity: "high" }),
|
||||
GRAPH_NODE_COLORS.high,
|
||||
GRAPH_NODE_BORDER_COLORS.high,
|
||||
true,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Medium",
|
||||
"Medium-risk finding node with severity-colored badge and glow.",
|
||||
buildNode(["ProwlerFinding"], { severity: "medium" }),
|
||||
GRAPH_NODE_COLORS.medium,
|
||||
GRAPH_NODE_BORDER_COLORS.medium,
|
||||
true,
|
||||
),
|
||||
buildVisualItem(
|
||||
"Low / Info",
|
||||
"Lower-risk informational findings use the info-style risk icon.",
|
||||
buildNode(["ProwlerFinding"], { severity: "info" }),
|
||||
GRAPH_NODE_COLORS.info,
|
||||
GRAPH_NODE_BORDER_COLORS.info,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const stateItems: LegendStateItem[] = [
|
||||
{
|
||||
label: "Selected node",
|
||||
description: "Active node with a stronger animated selection ring.",
|
||||
fillColor: GRAPH_NODE_COLORS.default,
|
||||
borderColor: GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
strokeWidth: 4,
|
||||
glowColor: GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
},
|
||||
{
|
||||
label: "Node with findings",
|
||||
description: "Resource node linked to one or more findings.",
|
||||
fillColor: GRAPH_NODE_COLORS.default,
|
||||
borderColor: GRAPH_ALERT_BORDER_COLOR,
|
||||
strokeWidth: 3,
|
||||
glowColor: GRAPH_ALERT_BORDER_COLOR,
|
||||
},
|
||||
];
|
||||
|
||||
const edgeItems: LegendEdgeItem[] = [
|
||||
{
|
||||
label: "Normal edge",
|
||||
description: "Relationship between resources in the attack path.",
|
||||
variant: EDGE_VARIANT.NORMAL,
|
||||
},
|
||||
{
|
||||
label: "Finding edge",
|
||||
description: "Animated dashed edge that connects a resource to a finding.",
|
||||
variant: EDGE_VARIANT.FINDING,
|
||||
},
|
||||
{
|
||||
label: "Highlighted path",
|
||||
description: "Orange path shown when hovering related graph nodes.",
|
||||
variant: EDGE_VARIANT.HIGHLIGHTED,
|
||||
},
|
||||
];
|
||||
|
||||
const hasLegendData = (data?: AttackPathGraphData): boolean =>
|
||||
(data?.nodes.length ?? 0) > 0;
|
||||
|
||||
const LegendSection = ({ title, children }: LegendSectionProps) => (
|
||||
<section className="bg-bg-neutral-secondary/60 border-border-neutral-primary flex w-full min-w-0 flex-col gap-2 rounded-lg border p-3 sm:w-fit sm:max-w-full">
|
||||
<h3 className="text-text-neutral-secondary text-[0.68rem] leading-none font-semibold tracking-wide uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const LegendItem = ({ label, description, children }: LegendItemProps) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="hover:bg-bg-neutral-tertiary/70 inline-flex cursor-help items-center gap-2 rounded-full px-1.5 py-1 transition-colors"
|
||||
role="img"
|
||||
aria-label={`${label}: ${description}`}
|
||||
>
|
||||
{children}
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const BadgePreview = ({
|
||||
Icon,
|
||||
fillColor,
|
||||
borderColor,
|
||||
glow,
|
||||
}: LegendVisualItem) => (
|
||||
<svg
|
||||
width={LEGEND_PREVIEW.SVG_SIZE}
|
||||
height={LEGEND_PREVIEW.SVG_SIZE}
|
||||
viewBox="0 0 36 36"
|
||||
aria-hidden="true"
|
||||
className="overflow-visible"
|
||||
>
|
||||
{glow && (
|
||||
<circle
|
||||
cx={LEGEND_PREVIEW.BADGE_CENTER}
|
||||
cy={LEGEND_PREVIEW.BADGE_CENTER}
|
||||
r={LEGEND_PREVIEW.BADGE_RADIUS + 4}
|
||||
stroke={borderColor}
|
||||
strokeOpacity={0.28}
|
||||
strokeWidth={6}
|
||||
fill={borderColor}
|
||||
fillOpacity={0.14}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={LEGEND_PREVIEW.BADGE_CENTER}
|
||||
cy={LEGEND_PREVIEW.BADGE_CENTER}
|
||||
r={LEGEND_PREVIEW.BADGE_RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.94}
|
||||
stroke={borderColor}
|
||||
strokeWidth={glow ? 2.5 : 1.5}
|
||||
/>
|
||||
<g
|
||||
transform={`translate(${LEGEND_PREVIEW.ICON_OFFSET}, ${LEGEND_PREVIEW.ICON_OFFSET})`}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
color="#ffffff"
|
||||
focusable="false"
|
||||
height={LEGEND_PREVIEW.ICON_SIZE}
|
||||
role="presentation"
|
||||
size={LEGEND_PREVIEW.ICON_SIZE}
|
||||
width={LEGEND_PREVIEW.ICON_SIZE}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StatePreview = ({
|
||||
fillColor,
|
||||
borderColor,
|
||||
strokeWidth,
|
||||
glowColor,
|
||||
}: LegendStateItem) => (
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" aria-hidden="true">
|
||||
{glowColor && (
|
||||
<circle cx="18" cy="18" r="20" fill={glowColor} fillOpacity="0.18" />
|
||||
)}
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="16"
|
||||
fill={fillColor}
|
||||
fillOpacity="0.92"
|
||||
stroke={borderColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EdgePreview = ({
|
||||
variant,
|
||||
edgeColor,
|
||||
}: {
|
||||
variant: EdgeVariant;
|
||||
edgeColor: string;
|
||||
}) => {
|
||||
const isFindingEdge = variant === EDGE_VARIANT.FINDING;
|
||||
const isHighlightedPath = variant === EDGE_VARIANT.HIGHLIGHTED;
|
||||
const strokeColor = isHighlightedPath
|
||||
? GRAPH_EDGE_HIGHLIGHT_COLOR
|
||||
: edgeColor;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="56"
|
||||
height="20"
|
||||
viewBox="0 0 56 20"
|
||||
aria-hidden="true"
|
||||
className="overflow-visible"
|
||||
>
|
||||
{isHighlightedPath && (
|
||||
<line
|
||||
x1="4"
|
||||
y1="10"
|
||||
x2="40"
|
||||
y2="10"
|
||||
stroke={GRAPH_EDGE_HIGHLIGHT_COLOR}
|
||||
strokeOpacity="0.32"
|
||||
strokeWidth="7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
<line
|
||||
x1="4"
|
||||
y1="10"
|
||||
x2="40"
|
||||
y2="10"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHighlightedPath ? 3 : 2.5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isFindingEdge ? "8 6" : undefined}
|
||||
/>
|
||||
<polygon points="40,5 52,10 40,15" fill={strokeColor} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Legend for attack path graph node types and edge styles
|
||||
* Compact semantic legend for the Attack Paths graph visual language.
|
||||
*/
|
||||
export const GraphLegend = ({ data }: GraphLegendProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const nodeTypes = extractNodeTypes(data?.nodes);
|
||||
|
||||
// Get edge color based on current theme
|
||||
const edgeColor =
|
||||
resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT;
|
||||
|
||||
// Check if there are any findings in the data
|
||||
const hasFindings = nodeTypes.some((type) =>
|
||||
type.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
const legendItems = generateLegendItems(nodeTypes, hasFindings);
|
||||
|
||||
if (legendItems.length === 0) {
|
||||
if (!hasLegendData(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const edgeColor =
|
||||
resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT;
|
||||
|
||||
return (
|
||||
<Card className="w-fit border-0">
|
||||
<CardContent className="gap-3 p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Node types section */}
|
||||
<div className="flex flex-col items-start gap-3 lg:flex-row lg:flex-wrap lg:items-center">
|
||||
<TooltipProvider>
|
||||
{legendItems.map((item) => (
|
||||
<Tooltip key={item.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex cursor-help items-center gap-2"
|
||||
role="img"
|
||||
aria-label={`${item.label}: ${item.description}`}
|
||||
>
|
||||
{item.shape === "hexagon" ? (
|
||||
<HexagonShape
|
||||
color={item.color}
|
||||
borderColor={item.borderColor}
|
||||
/>
|
||||
) : item.shape === "cloud" ? (
|
||||
<GlobeShape
|
||||
color={item.color}
|
||||
borderColor={item.borderColor}
|
||||
/>
|
||||
) : (
|
||||
<PillShape
|
||||
color={item.color}
|
||||
borderColor={item.borderColor}
|
||||
/>
|
||||
)}
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{item.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Card className="w-full border-0">
|
||||
<CardContent className="p-3">
|
||||
<TooltipProvider>
|
||||
<div className="flex w-full flex-wrap items-stretch gap-2">
|
||||
<LegendSection title="Provider roots">
|
||||
<LegendItem {...providerRootItem}>
|
||||
<BadgePreview {...providerRootItem} />
|
||||
</LegendItem>
|
||||
</LegendSection>
|
||||
|
||||
<LegendSection title="Resource categories">
|
||||
{resourceCategoryItems.map((item) => (
|
||||
<LegendItem key={item.label} {...item}>
|
||||
<BadgePreview {...item} />
|
||||
</LegendItem>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</LegendSection>
|
||||
|
||||
{/* Edge types section */}
|
||||
<div className="border-border-neutral-primary flex flex-col items-start gap-3 border-t pt-3 lg:flex-row lg:flex-wrap lg:items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex cursor-help items-center gap-2"
|
||||
role="img"
|
||||
aria-label="Solid line: Resource connection"
|
||||
>
|
||||
<EdgeLine dashed={false} edgeColor={edgeColor} />
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Resource Connection
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Connection between infrastructure resources
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LegendSection title="Findings by risk">
|
||||
{findingRiskItems.map((item) => (
|
||||
<LegendItem key={item.label} {...item}>
|
||||
<BadgePreview {...item} />
|
||||
</LegendItem>
|
||||
))}
|
||||
</LegendSection>
|
||||
|
||||
{hasFindings && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex cursor-help items-center gap-2"
|
||||
role="img"
|
||||
aria-label="Dashed line: Finding connection"
|
||||
>
|
||||
<EdgeLine dashed={true} edgeColor={edgeColor} />
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Finding Connection
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Connection to a security finding
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<LegendSection title="States">
|
||||
{stateItems.map((item) => (
|
||||
<LegendItem key={item.label} {...item}>
|
||||
<StatePreview {...item} />
|
||||
</LegendItem>
|
||||
))}
|
||||
</LegendSection>
|
||||
|
||||
{/* Zoom control hint */}
|
||||
<div className="border-border-neutral-primary flex items-center gap-2 border-t pt-3">
|
||||
<kbd className="bg-bg-neutral-tertiary text-text-neutral-secondary rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
Ctrl
|
||||
</kbd>
|
||||
<span className="text-text-neutral-secondary text-xs">+</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Scroll to zoom
|
||||
</span>
|
||||
<LegendSection title="Edges">
|
||||
{edgeItems.map((item) => (
|
||||
<LegendItem key={item.label} {...item}>
|
||||
<EdgePreview variant={item.variant} edgeColor={edgeColor} />
|
||||
</LegendItem>
|
||||
))}
|
||||
</LegendSection>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+82
@@ -244,6 +244,34 @@ describe("exploring the graph", () => {
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("choosing Show findings re-fits around the resource and its findings", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
const initialViewport = graph.viewportTransform;
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
await graph.waitFor(
|
||||
() => graph.viewportTransform !== initialViewport,
|
||||
2000,
|
||||
);
|
||||
|
||||
const contextualViewport = graph.viewportTransform;
|
||||
|
||||
await graph.fit();
|
||||
|
||||
await graph.waitFor(
|
||||
() => graph.viewportTransform !== contextualViewport,
|
||||
2000,
|
||||
);
|
||||
});
|
||||
|
||||
test("expanded resources offer Hide findings in the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
@@ -265,6 +293,60 @@ describe("exploring the graph", () => {
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("choosing Hide findings re-fits the remaining visible graph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
await graph.waitForTransition();
|
||||
|
||||
const expandedViewport = graph.viewportTransform;
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseHideFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
await graph.waitFor(
|
||||
() => graph.viewportTransform !== expandedViewport,
|
||||
2000,
|
||||
);
|
||||
});
|
||||
|
||||
test("returning from a finding keeps the expanded findings context fitted", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.large(20));
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(16);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
await graph.waitForTransition();
|
||||
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
|
||||
await graph.exitFilteredView();
|
||||
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
await graph.waitForTransition();
|
||||
|
||||
const expandedContextViewport = graph.viewportTransform;
|
||||
|
||||
await graph.fit();
|
||||
|
||||
await graph.waitFor(
|
||||
() => graph.viewportTransform !== expandedContextViewport,
|
||||
2000,
|
||||
);
|
||||
});
|
||||
|
||||
test("choosing View node details opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user