Skip to content

Commit 30143db

Browse files
committed
[Cloud Security] Added popover support for graph component (#199053)
## Summary Added popover support to the graph component. In order to scale the rendering component of nodes, we prefer not to add popover per node but to manage a single popover for each use-case. In the popover stories you can see an example of two different popovers being triggered by different buttons on the node. <details> <summary>Popover support 📹 </summary> https://github.com/user-attachments/assets/cb5bc2ce-037a-4f9b-b71a-f95a9362dde0 </details> <details> <summary>Dark mode support 📹 </summary> https://github.com/user-attachments/assets/a55f2a88-ed07-40e2-9404-30a2042bf4fc </details> ### How to test To test this PR you can run ``` yarn storybook cloud_security_posture_packages ``` And to test the alerts flyout (for regression test): Toggle feature flag in kibana.dev.yml ```yaml xpack.securitySolution.enableExperimental: ['graphVisualizationInFlyoutEnabled'] ``` Load mocked data ```bash node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/security_alerts \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 ``` 1. Go to the alerts page 2. Change the query time range to show alerts from the 13th of October 2024 (**IMPORTANT**) 3. Open the alerts flyout 5. Scroll to see the graph visualization : D ### Related PRs - #196034 - #195307 ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) (cherry picked from commit f3de593)
1 parent 1456012 commit 30143db

18 files changed

Lines changed: 1422 additions & 155 deletions

File tree

x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export const SvgDefsMarker = () => {
128128
const { euiTheme } = useEuiTheme();
129129

130130
return (
131-
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
131+
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
132132
<defs>
133133
<Marker id="primary" color={euiTheme.colors.primary} />
134134
<Marker id="danger" color={euiTheme.colors.danger} />

x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx

Lines changed: 117 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* 2.0.
66
*/
77

8-
import React, { useMemo, useRef, useState, useCallback } from 'react';
8+
import React, { useState, useCallback, useEffect, useRef } from 'react';
9+
import { size, isEmpty, isEqual, xorWith } from 'lodash';
910
import {
1011
Background,
1112
Controls,
@@ -14,7 +15,8 @@ import {
1415
useEdgesState,
1516
useNodesState,
1617
} from '@xyflow/react';
17-
import type { Edge, Node } from '@xyflow/react';
18+
import type { Edge, FitViewOptions, Node, ReactFlowInstance } from '@xyflow/react';
19+
import { useGeneratedHtmlId } from '@elastic/eui';
1820
import type { CommonProps } from '@elastic/eui';
1921
import { SvgDefsMarker } from '../edge/styles';
2022
import {
@@ -33,9 +35,23 @@ import type { EdgeViewModel, NodeViewModel } from '../types';
3335
import '@xyflow/react/dist/style.css';
3436

3537
export interface GraphProps extends CommonProps {
38+
/**
39+
* Array of node view models to be rendered in the graph.
40+
*/
3641
nodes: NodeViewModel[];
42+
/**
43+
* Array of edge view models to be rendered in the graph.
44+
*/
3745
edges: EdgeViewModel[];
46+
/**
47+
* Determines whether the graph is interactive (allows panning, zooming, etc.).
48+
* When set to false, the graph is locked and user interactions are disabled, effectively putting it in view-only mode.
49+
*/
3850
interactive: boolean;
51+
/**
52+
* Determines whether the graph is locked. Nodes and edges are still interactive, but the graph itself is not.
53+
*/
54+
isLocked?: boolean;
3955
}
4056

4157
const nodeTypes = {
@@ -66,28 +82,47 @@ const edgeTypes = {
6682
*
6783
* @returns {JSX.Element} The rendered Graph component.
6884
*/
69-
export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest }) => {
70-
const layoutCalled = useRef(false);
71-
const [isGraphLocked, setIsGraphLocked] = useState(interactive);
72-
const { initialNodes, initialEdges } = useMemo(
73-
() => processGraph(nodes, edges, isGraphLocked),
74-
[nodes, edges, isGraphLocked]
75-
);
76-
77-
const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes);
78-
const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges);
79-
80-
if (!layoutCalled.current) {
81-
const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState);
82-
setNodes(layoutedNodes);
83-
layoutCalled.current = true;
84-
}
85+
export const Graph: React.FC<GraphProps> = ({
86+
nodes,
87+
edges,
88+
interactive,
89+
isLocked = false,
90+
...rest
91+
}) => {
92+
const backgroundId = useGeneratedHtmlId();
93+
const fitViewRef = useRef<
94+
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
95+
>(null);
96+
const currNodesRef = useRef<NodeViewModel[]>([]);
97+
const currEdgesRef = useRef<EdgeViewModel[]>([]);
98+
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
99+
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
100+
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
101+
102+
useEffect(() => {
103+
// On nodes or edges changes reset the graph and re-layout
104+
if (
105+
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
106+
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
107+
) {
108+
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
109+
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);
110+
111+
setNodes(layoutedNodes);
112+
setEdges(initialEdges);
113+
currNodesRef.current = nodes;
114+
currEdgesRef.current = edges;
115+
setTimeout(() => {
116+
fitViewRef.current?.();
117+
}, 30);
118+
}
119+
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
85120

86121
const onInteractiveStateChange = useCallback(
87122
(interactiveStatus: boolean): void => {
88-
setIsGraphLocked(interactiveStatus);
89-
setNodes((prevNodes) =>
90-
prevNodes.map((node) => ({
123+
setIsGraphInteractive(interactiveStatus);
124+
setNodes((currNodes) =>
125+
currNodes.map((node) => ({
91126
...node,
92127
data: {
93128
...node.data,
@@ -99,40 +134,47 @@ export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest
99134
[setNodes]
100135
);
101136

137+
const onInitCallback = useCallback(
138+
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
139+
window.requestAnimationFrame(() => xyflow.fitView());
140+
fitViewRef.current = xyflow.fitView;
141+
142+
// When the graph is not initialized as interactive, we need to fit the view on resize
143+
if (!interactive) {
144+
const resizeObserver = new ResizeObserver(() => {
145+
xyflow.fitView();
146+
});
147+
resizeObserver.observe(document.querySelector('.react-flow') as Element);
148+
return () => resizeObserver.disconnect();
149+
}
150+
},
151+
[interactive]
152+
);
153+
102154
return (
103155
<div {...rest}>
104156
<SvgDefsMarker />
105157
<ReactFlow
106158
fitView={true}
107-
onInit={(xyflow) => {
108-
window.requestAnimationFrame(() => xyflow.fitView());
109-
110-
// When the graph is not initialized as interactive, we need to fit the view on resize
111-
if (!interactive) {
112-
const resizeObserver = new ResizeObserver(() => {
113-
xyflow.fitView();
114-
});
115-
resizeObserver.observe(document.querySelector('.react-flow') as Element);
116-
return () => resizeObserver.disconnect();
117-
}
118-
}}
159+
onInit={onInitCallback}
119160
nodeTypes={nodeTypes}
120161
edgeTypes={edgeTypes}
121162
nodes={nodesState}
122163
edges={edgesState}
123164
onNodesChange={onNodesChange}
124165
onEdgesChange={onEdgesChange}
125166
proOptions={{ hideAttribution: true }}
126-
panOnDrag={isGraphLocked}
127-
zoomOnScroll={isGraphLocked}
128-
zoomOnPinch={isGraphLocked}
129-
zoomOnDoubleClick={isGraphLocked}
130-
preventScrolling={isGraphLocked}
131-
nodesDraggable={interactive && isGraphLocked}
167+
panOnDrag={isGraphInteractive && !isLocked}
168+
zoomOnScroll={isGraphInteractive && !isLocked}
169+
zoomOnPinch={isGraphInteractive && !isLocked}
170+
zoomOnDoubleClick={isGraphInteractive && !isLocked}
171+
preventScrolling={interactive}
172+
nodesDraggable={interactive && isGraphInteractive && !isLocked}
132173
maxZoom={1.3}
174+
minZoom={0.1}
133175
>
134176
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
135-
<Background />
177+
<Background id={backgroundId} />{' '}
136178
</ReactFlow>
137179
</div>
138180
);
@@ -173,32 +215,41 @@ const processGraph = (
173215
return node;
174216
});
175217

176-
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel.map((edgeData) => {
177-
const isIn =
178-
nodesById[edgeData.source].shape !== 'label' && nodesById[edgeData.target].shape === 'group';
179-
const isInside =
180-
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape === 'label';
181-
const isOut =
182-
nodesById[edgeData.source].shape === 'label' && nodesById[edgeData.target].shape === 'group';
183-
const isOutside =
184-
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape !== 'label';
185-
186-
return {
187-
id: edgeData.id,
188-
type: 'default',
189-
source: edgeData.source,
190-
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
191-
target: edgeData.target,
192-
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
193-
focusable: false,
194-
selectable: false,
195-
data: {
196-
...edgeData,
197-
sourceShape: nodesById[edgeData.source].shape,
198-
targetShape: nodesById[edgeData.target].shape,
199-
},
200-
};
201-
});
218+
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel
219+
.filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target])
220+
.map((edgeData) => {
221+
const isIn =
222+
nodesById[edgeData.source].shape !== 'label' &&
223+
nodesById[edgeData.target].shape === 'group';
224+
const isInside =
225+
nodesById[edgeData.source].shape === 'group' &&
226+
nodesById[edgeData.target].shape === 'label';
227+
const isOut =
228+
nodesById[edgeData.source].shape === 'label' &&
229+
nodesById[edgeData.target].shape === 'group';
230+
const isOutside =
231+
nodesById[edgeData.source].shape === 'group' &&
232+
nodesById[edgeData.target].shape !== 'label';
233+
234+
return {
235+
id: edgeData.id,
236+
type: 'default',
237+
source: edgeData.source,
238+
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
239+
target: edgeData.target,
240+
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
241+
focusable: false,
242+
selectable: false,
243+
data: {
244+
...edgeData,
245+
sourceShape: nodesById[edgeData.source].shape,
246+
targetShape: nodesById[edgeData.target].shape,
247+
},
248+
};
249+
});
202250

203251
return { initialNodes, initialEdges };
204252
};
253+
254+
const isArrayOfObjectsEqual = (x: object[], y: object[]) =>
255+
size(x) === size(y) && isEmpty(xorWith(x, y, isEqual));

0 commit comments

Comments
 (0)