Skip to content

Commit

Permalink
[8.x] [Cloud Security] Added popover support for graph component (#19…
Browse files Browse the repository at this point in the history
…9053) (#199889)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Cloud Security] Added popover support for graph component
(#199053)](#199053)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Kfir
Peled","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-11-12T23:54:09Z","message":"[Cloud
Security] Added popover support for graph component (#199053)\n\n##
Summary\r\n\r\nAdded popover support to the graph component. \r\nIn
order to scale the rendering component of nodes, we prefer not to
add\r\npopover per node but to manage a single popover for each
use-case. In\r\nthe popover stories you can see an example of two
different popovers\r\nbeing triggered by different buttons on the
node.\r\n\r\n<details>\r\n<summary>Popover support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb5bc2ce-037a-4f9b-b71a-f95a9362dde0\r\n\r\n</details>\r\n\r\n<details>\r\n<summary>Dark
mode support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/a55f2a88-ed07-40e2-9404-30a2042bf4fc\r\n\r\n</details>\r\n\r\n###
How to test\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nAnd to test the
alerts flyout (for regression test):\r\n\r\nToggle feature flag in
kibana.dev.yml\r\n\r\n```yaml\r\nxpack.securitySolution.enableExperimental:
['graphVisualizationInFlyoutEnabled']\r\n```\r\n\r\nLoad mocked
data\r\n\r\n```bash\r\nnode scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit
\\ \r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n\r\nnode
scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/security_alerts
\\\r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n```\r\n\r\n1. Go
to the alerts page\r\n2. Change the query time range to show alerts from
the 13th of October\r\n2024 (**IMPORTANT**)\r\n3. Open the alerts
flyout\r\n5. Scroll to see the graph visualization : D\r\n\r\n\r\n###
Related PRs\r\n\r\n- https://github.com/elastic/kibana/pull/196034\r\n-
https://github.com/elastic/kibana/pull/195307\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] This
renders correctly on smaller devices using a responsive\r\nlayout. (You
can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)","sha":"f3de5930493fb34a174e14f6dabdc1faf0722cad","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor"],"title":"[Cloud
Security] Added popover support for graph
component","number":199053,"url":"https://github.com/elastic/kibana/pull/199053","mergeCommit":{"message":"[Cloud
Security] Added popover support for graph component (#199053)\n\n##
Summary\r\n\r\nAdded popover support to the graph component. \r\nIn
order to scale the rendering component of nodes, we prefer not to
add\r\npopover per node but to manage a single popover for each
use-case. In\r\nthe popover stories you can see an example of two
different popovers\r\nbeing triggered by different buttons on the
node.\r\n\r\n<details>\r\n<summary>Popover support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb5bc2ce-037a-4f9b-b71a-f95a9362dde0\r\n\r\n</details>\r\n\r\n<details>\r\n<summary>Dark
mode support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/a55f2a88-ed07-40e2-9404-30a2042bf4fc\r\n\r\n</details>\r\n\r\n###
How to test\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nAnd to test the
alerts flyout (for regression test):\r\n\r\nToggle feature flag in
kibana.dev.yml\r\n\r\n```yaml\r\nxpack.securitySolution.enableExperimental:
['graphVisualizationInFlyoutEnabled']\r\n```\r\n\r\nLoad mocked
data\r\n\r\n```bash\r\nnode scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit
\\ \r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n\r\nnode
scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/security_alerts
\\\r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n```\r\n\r\n1. Go
to the alerts page\r\n2. Change the query time range to show alerts from
the 13th of October\r\n2024 (**IMPORTANT**)\r\n3. Open the alerts
flyout\r\n5. Scroll to see the graph visualization : D\r\n\r\n\r\n###
Related PRs\r\n\r\n- https://github.com/elastic/kibana/pull/196034\r\n-
https://github.com/elastic/kibana/pull/195307\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] This
renders correctly on smaller devices using a responsive\r\nlayout. (You
can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)","sha":"f3de5930493fb34a174e14f6dabdc1faf0722cad"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/199053","number":199053,"mergeCommit":{"message":"[Cloud
Security] Added popover support for graph component (#199053)\n\n##
Summary\r\n\r\nAdded popover support to the graph component. \r\nIn
order to scale the rendering component of nodes, we prefer not to
add\r\npopover per node but to manage a single popover for each
use-case. In\r\nthe popover stories you can see an example of two
different popovers\r\nbeing triggered by different buttons on the
node.\r\n\r\n<details>\r\n<summary>Popover support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb5bc2ce-037a-4f9b-b71a-f95a9362dde0\r\n\r\n</details>\r\n\r\n<details>\r\n<summary>Dark
mode support 📹
</summary>\r\n\r\n\r\nhttps://github.com/user-attachments/assets/a55f2a88-ed07-40e2-9404-30a2042bf4fc\r\n\r\n</details>\r\n\r\n###
How to test\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nAnd to test the
alerts flyout (for regression test):\r\n\r\nToggle feature flag in
kibana.dev.yml\r\n\r\n```yaml\r\nxpack.securitySolution.enableExperimental:
['graphVisualizationInFlyoutEnabled']\r\n```\r\n\r\nLoad mocked
data\r\n\r\n```bash\r\nnode scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit
\\ \r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n\r\nnode
scripts/es_archiver load
x-pack/test/cloud_security_posture_functional/es_archives/security_alerts
\\\r\n --es-url http://elastic:changeme@localhost:9200 \\\r\n
--kibana-url http://elastic:changeme@localhost:5601\r\n```\r\n\r\n1. Go
to the alerts page\r\n2. Change the query time range to show alerts from
the 13th of October\r\n2024 (**IMPORTANT**)\r\n3. Open the alerts
flyout\r\n5. Scroll to see the graph visualization : D\r\n\r\n\r\n###
Related PRs\r\n\r\n- https://github.com/elastic/kibana/pull/196034\r\n-
https://github.com/elastic/kibana/pull/195307\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] This
renders correctly on smaller devices using a responsive\r\nlayout. (You
can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)","sha":"f3de5930493fb34a174e14f6dabdc1faf0722cad"}}]}]
BACKPORT-->

Co-authored-by: Kfir Peled <[email protected]>
  • Loading branch information
kibanamachine and kfirpeled authored Nov 13, 2024
1 parent 73376fc commit b2b274f
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 b2b274f

Please sign in to comment.