Skip to content

Commit

Permalink
[Cloud Security] Added popover support for graph component (#199053)
Browse files Browse the repository at this point in the history
## 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)
  • Loading branch information
kfirpeled committed Nov 12, 2024
1 parent 1456012 commit 30143db
Show file tree
Hide file tree
Showing 18 changed files with 1,422 additions and 155 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const SvgDefsMarker = () => {
const { euiTheme } = useEuiTheme();

return (
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
<defs>
<Marker id="primary" color={euiTheme.colors.primary} />
<Marker id="danger" color={euiTheme.colors.danger} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* 2.0.
*/

import React, { useMemo, useRef, useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { size, isEmpty, isEqual, xorWith } from 'lodash';
import {
Background,
Controls,
Expand All @@ -14,7 +15,8 @@ import {
useEdgesState,
useNodesState,
} from '@xyflow/react';
import type { Edge, Node } from '@xyflow/react';
import type { Edge, FitViewOptions, Node, ReactFlowInstance } from '@xyflow/react';
import { useGeneratedHtmlId } from '@elastic/eui';
import type { CommonProps } from '@elastic/eui';
import { SvgDefsMarker } from '../edge/styles';
import {
Expand All @@ -33,9 +35,23 @@ import type { EdgeViewModel, NodeViewModel } from '../types';
import '@xyflow/react/dist/style.css';

export interface GraphProps extends CommonProps {
/**
* Array of node view models to be rendered in the graph.
*/
nodes: NodeViewModel[];
/**
* Array of edge view models to be rendered in the graph.
*/
edges: EdgeViewModel[];
/**
* Determines whether the graph is interactive (allows panning, zooming, etc.).
* When set to false, the graph is locked and user interactions are disabled, effectively putting it in view-only mode.
*/
interactive: boolean;
/**
* Determines whether the graph is locked. Nodes and edges are still interactive, but the graph itself is not.
*/
isLocked?: boolean;
}

const nodeTypes = {
Expand Down Expand Up @@ -66,28 +82,47 @@ const edgeTypes = {
*
* @returns {JSX.Element} The rendered Graph component.
*/
export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest }) => {
const layoutCalled = useRef(false);
const [isGraphLocked, setIsGraphLocked] = useState(interactive);
const { initialNodes, initialEdges } = useMemo(
() => processGraph(nodes, edges, isGraphLocked),
[nodes, edges, isGraphLocked]
);

const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges);

if (!layoutCalled.current) {
const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState);
setNodes(layoutedNodes);
layoutCalled.current = true;
}
export const Graph: React.FC<GraphProps> = ({
nodes,
edges,
interactive,
isLocked = false,
...rest
}) => {
const backgroundId = useGeneratedHtmlId();
const fitViewRef = useRef<
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
>(null);
const currNodesRef = useRef<NodeViewModel[]>([]);
const currEdgesRef = useRef<EdgeViewModel[]>([]);
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);

useEffect(() => {
// On nodes or edges changes reset the graph and re-layout
if (
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
) {
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);

setNodes(layoutedNodes);
setEdges(initialEdges);
currNodesRef.current = nodes;
currEdgesRef.current = edges;
setTimeout(() => {
fitViewRef.current?.();
}, 30);
}
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);

const onInteractiveStateChange = useCallback(
(interactiveStatus: boolean): void => {
setIsGraphLocked(interactiveStatus);
setNodes((prevNodes) =>
prevNodes.map((node) => ({
setIsGraphInteractive(interactiveStatus);
setNodes((currNodes) =>
currNodes.map((node) => ({
...node,
data: {
...node.data,
Expand All @@ -99,40 +134,47 @@ export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest
[setNodes]
);

const onInitCallback = useCallback(
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
window.requestAnimationFrame(() => xyflow.fitView());
fitViewRef.current = xyflow.fitView;

// When the graph is not initialized as interactive, we need to fit the view on resize
if (!interactive) {
const resizeObserver = new ResizeObserver(() => {
xyflow.fitView();
});
resizeObserver.observe(document.querySelector('.react-flow') as Element);
return () => resizeObserver.disconnect();
}
},
[interactive]
);

return (
<div {...rest}>
<SvgDefsMarker />
<ReactFlow
fitView={true}
onInit={(xyflow) => {
window.requestAnimationFrame(() => xyflow.fitView());

// When the graph is not initialized as interactive, we need to fit the view on resize
if (!interactive) {
const resizeObserver = new ResizeObserver(() => {
xyflow.fitView();
});
resizeObserver.observe(document.querySelector('.react-flow') as Element);
return () => resizeObserver.disconnect();
}
}}
onInit={onInitCallback}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodesState}
edges={edgesState}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{ hideAttribution: true }}
panOnDrag={isGraphLocked}
zoomOnScroll={isGraphLocked}
zoomOnPinch={isGraphLocked}
zoomOnDoubleClick={isGraphLocked}
preventScrolling={isGraphLocked}
nodesDraggable={interactive && isGraphLocked}
panOnDrag={isGraphInteractive && !isLocked}
zoomOnScroll={isGraphInteractive && !isLocked}
zoomOnPinch={isGraphInteractive && !isLocked}
zoomOnDoubleClick={isGraphInteractive && !isLocked}
preventScrolling={interactive}
nodesDraggable={interactive && isGraphInteractive && !isLocked}
maxZoom={1.3}
minZoom={0.1}
>
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
<Background />
<Background id={backgroundId} />{' '}
</ReactFlow>
</div>
);
Expand Down Expand Up @@ -173,32 +215,41 @@ const processGraph = (
return node;
});

const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel.map((edgeData) => {
const isIn =
nodesById[edgeData.source].shape !== 'label' && nodesById[edgeData.target].shape === 'group';
const isInside =
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape === 'label';
const isOut =
nodesById[edgeData.source].shape === 'label' && nodesById[edgeData.target].shape === 'group';
const isOutside =
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape !== 'label';

return {
id: edgeData.id,
type: 'default',
source: edgeData.source,
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
target: edgeData.target,
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
focusable: false,
selectable: false,
data: {
...edgeData,
sourceShape: nodesById[edgeData.source].shape,
targetShape: nodesById[edgeData.target].shape,
},
};
});
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel
.filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target])
.map((edgeData) => {
const isIn =
nodesById[edgeData.source].shape !== 'label' &&
nodesById[edgeData.target].shape === 'group';
const isInside =
nodesById[edgeData.source].shape === 'group' &&
nodesById[edgeData.target].shape === 'label';
const isOut =
nodesById[edgeData.source].shape === 'label' &&
nodesById[edgeData.target].shape === 'group';
const isOutside =
nodesById[edgeData.source].shape === 'group' &&
nodesById[edgeData.target].shape !== 'label';

return {
id: edgeData.id,
type: 'default',
source: edgeData.source,
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
target: edgeData.target,
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
focusable: false,
selectable: false,
data: {
...edgeData,
sourceShape: nodesById[edgeData.source].shape,
targetShape: nodesById[edgeData.target].shape,
},
};
});

return { initialNodes, initialEdges };
};

const isArrayOfObjectsEqual = (x: object[], y: object[]) =>
size(x) === size(y) && isEmpty(xorWith(x, y, isEqual));
Loading

0 comments on commit 30143db

Please sign in to comment.