mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 12:24:17 +00:00
178 lines
5.0 KiB
TypeScript
178 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { type NodeProps } from "@xyflow/react";
|
|
|
|
import { GraphNodeShell } from "@/components/attack-paths/graph/nodes/graph-node-shell";
|
|
import {
|
|
GRAPH_COUNT_BADGE_STROKE_COLOR,
|
|
resolveNodeColors,
|
|
resolveNodeVisual,
|
|
} from "@/lib/attack-paths";
|
|
import { GROUP_PROPS } from "@/lib/attack-paths/group-graph";
|
|
import { GROUP_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions";
|
|
import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
|
|
import type { GraphNode } from "@/types/attack-paths";
|
|
|
|
interface GroupNodeData {
|
|
graphNode: GraphNode;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const NODE_WIDTH = GROUP_NODE_DIMENSIONS.WIDTH;
|
|
const NODE_HEIGHT = GROUP_NODE_DIMENSIONS.HEIGHT;
|
|
const NAME_MAX_CHARS = GROUP_NODE_DIMENSIONS.LABEL_MAX_CHARS;
|
|
const NAME_MAX_LINES = GROUP_NODE_DIMENSIONS.LABEL_MAX_LINES;
|
|
const BADGE_SIZE = 44;
|
|
const BADGE_RADIUS = BADGE_SIZE / 2;
|
|
const BADGE_CENTER_X = NODE_WIDTH / 2;
|
|
const BADGE_CENTER_Y = 26;
|
|
const ICON_SIZE = 28;
|
|
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
|
|
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
|
|
// Count badge, top-right of the class icon.
|
|
const COUNT_CX = BADGE_CENTER_X + BADGE_RADIUS - 2;
|
|
const COUNT_CY = BADGE_CENTER_Y - BADGE_RADIUS + 2;
|
|
const COUNT_R = 11;
|
|
const TEXT_X = BADGE_CENTER_X;
|
|
const NAME_Y = 62;
|
|
const NAME_LINE_HEIGHT = 13;
|
|
const COUNT_LABEL_Y = 96;
|
|
const HINT_Y = 112;
|
|
|
|
export const GroupNode = ({ data, selected }: NodeProps) => {
|
|
const { graphNode } = data as GroupNodeData;
|
|
const classLabel = String(graphNode.properties[GROUP_PROPS.CLASS] ?? "");
|
|
const className = String(graphNode.properties[GROUP_PROPS.CLASS_NAME] ?? "");
|
|
const count = Number(graphNode.properties[GROUP_PROPS.COUNT] ?? 0);
|
|
// Members carry findings -> red cue, so the user knows to expand (matches the
|
|
// per-resource "red = findings" convention).
|
|
const hasFindings = Boolean(graphNode.properties[GROUP_PROPS.HAS_FINDINGS]);
|
|
|
|
// Resolve the class icon/colors from a representative node of the class.
|
|
const representative: GraphNode = {
|
|
id: graphNode.id,
|
|
labels: [classLabel],
|
|
properties: {},
|
|
};
|
|
const visual = resolveNodeVisual(representative);
|
|
const Icon = visual.Icon;
|
|
const { fillColor, borderColor } = resolveNodeColors({
|
|
labels: [classLabel],
|
|
properties: {},
|
|
selected,
|
|
hasFindings,
|
|
});
|
|
|
|
const fullName = className || visual.description;
|
|
const name = getNodeLabelDisplay(fullName, NAME_MAX_CHARS, NAME_MAX_LINES);
|
|
|
|
return (
|
|
<GraphNodeShell
|
|
width={NODE_WIDTH}
|
|
height={NODE_HEIGHT}
|
|
badgeCenterX={BADGE_CENTER_X}
|
|
badgeCenterY={BADGE_CENTER_Y}
|
|
badgeRadius={BADGE_RADIUS}
|
|
testId="attack-path-group-node"
|
|
svgClassName="cursor-pointer"
|
|
tooltip={name.isTruncated ? fullName : undefined}
|
|
>
|
|
{hasFindings && (
|
|
<circle
|
|
cx={BADGE_CENTER_X}
|
|
cy={BADGE_CENTER_Y}
|
|
r={BADGE_RADIUS + 5}
|
|
fill={borderColor}
|
|
fillOpacity={0.26}
|
|
pointerEvents="none"
|
|
/>
|
|
)}
|
|
<circle
|
|
cx={BADGE_CENTER_X}
|
|
cy={BADGE_CENTER_Y}
|
|
r={BADGE_RADIUS}
|
|
fill={fillColor}
|
|
fillOpacity={0.92}
|
|
stroke={borderColor}
|
|
strokeWidth={selected ? 4 : hasFindings ? 3 : 1.5}
|
|
/>
|
|
<g
|
|
aria-label={`${visual.description} icon`}
|
|
role="img"
|
|
transform={`translate(${ICON_X}, ${ICON_Y})`}
|
|
>
|
|
<Icon
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
height={ICON_SIZE}
|
|
role="presentation"
|
|
size={ICON_SIZE}
|
|
width={ICON_SIZE}
|
|
/>
|
|
</g>
|
|
{/* count badge */}
|
|
<circle
|
|
cx={COUNT_CX}
|
|
cy={COUNT_CY}
|
|
r={COUNT_R}
|
|
fill={borderColor}
|
|
stroke={GRAPH_COUNT_BADGE_STROKE_COLOR}
|
|
strokeWidth={2}
|
|
/>
|
|
<text
|
|
x={COUNT_CX}
|
|
y={COUNT_CY + 1}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
fontSize="11px"
|
|
fontWeight="700"
|
|
fill="#ffffff"
|
|
pointerEvents="none"
|
|
>
|
|
{count}
|
|
</text>
|
|
<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"
|
|
>
|
|
{name.lines.map((line, index) => (
|
|
<tspan
|
|
key={`${line}-${index}`}
|
|
x={TEXT_X}
|
|
y={NAME_Y + index * NAME_LINE_HEIGHT}
|
|
fontSize="11px"
|
|
fontWeight="600"
|
|
>
|
|
{line}
|
|
</tspan>
|
|
))}
|
|
</text>
|
|
<text
|
|
x={TEXT_X}
|
|
y={COUNT_LABEL_Y}
|
|
textAnchor="middle"
|
|
fontSize="9px"
|
|
fill="rgba(255,255,255,0.8)"
|
|
pointerEvents="none"
|
|
>
|
|
{count} {count === 1 ? "resource" : "resources"}
|
|
</text>
|
|
<text
|
|
x={TEXT_X}
|
|
y={HINT_Y}
|
|
textAnchor="middle"
|
|
fontSize="8px"
|
|
fill="rgba(255,255,255,0.55)"
|
|
pointerEvents="none"
|
|
>
|
|
click to expand
|
|
</text>
|
|
</GraphNodeShell>
|
|
);
|
|
};
|