[8.x] [Cloud Security] CDR Graph - fix labels overlap when there are multiple labels (#204020) (#204098)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Cloud Security] CDR Graph - fix labels overlap when there are
multiple labels
(#204020)](https://github.com/elastic/kibana/pull/204020)

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

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

<!--BACKPORT [{"author":{"name":"Kfir
Peled","email":"61654899+kfirpeled@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-12-12T17:45:00Z","message":"[Cloud
Security] CDR Graph - fix labels overlap when there are multiple labels
(#204020)","sha":"231f1b3fca277d947ff8de23fbceac3e28ea88eb","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor"],"title":"[Cloud
Security] CDR Graph - fix labels overlap when there are multiple
labels","number":204020,"url":"https://github.com/elastic/kibana/pull/204020","mergeCommit":{"message":"[Cloud
Security] CDR Graph - fix labels overlap when there are multiple labels
(#204020)","sha":"231f1b3fca277d947ff8de23fbceac3e28ea88eb"}},"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/204020","number":204020,"mergeCommit":{"message":"[Cloud
Security] CDR Graph - fix labels overlap when there are multiple labels
(#204020)","sha":"231f1b3fca277d947ff8de23fbceac3e28ea88eb"}}]}]
BACKPORT-->

Co-authored-by: Kfir Peled <61654899+kfirpeled@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-12-13 06:52:44 +11:00 committed by GitHub
parent 3e91c7f54b
commit 0b9976ae41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 8 deletions

View file

@ -9,6 +9,10 @@ module.exports = {
preset: '@kbn/test',
roots: ['<rootDir>/x-pack/packages/kbn-cloud-security-posture/graph'],
rootDir: '../../../..',
transform: {
'^.+\\.(js|tsx?)$':
'<rootDir>/x-pack/packages/kbn-cloud-security-posture/storybook/config/babel_with_emotion.ts',
},
setupFiles: ['jest-canvas-mock'],
setupFilesAfterEnv: ['<rootDir>/x-pack/packages/kbn-cloud-security-posture/graph/setup_tests.ts'],
};

View file

@ -50,6 +50,10 @@ export const layoutGraph = (
nodesById[node.id] = node;
}
if (node.parentId) {
return;
}
g.setNode(node.id, {
...node,
...size,
@ -59,6 +63,14 @@ export const layoutGraph = (
Dagre.layout(g);
const layoutedNodes = nodes.map((node) => {
// For grouped nodes, we want to keep the original position relative to the parent
if (node.data.shape === 'label' && node.data.parentId) {
return {
...node,
position: nodesById[node.data.id].position,
};
}
const dagreNode = g.node(node.data.id);
// We are shifting the dagre node position (anchor=center center) to the top left
@ -66,13 +78,7 @@ export const layoutGraph = (
const x = dagreNode.x - (dagreNode.width ?? 0) / 2;
const y = dagreNode.y - (dagreNode.height ?? 0) / 2;
// For grouped nodes, we want to keep the original position relative to the parent
if (node.data.shape === 'label' && node.data.parentId) {
return {
...node,
position: nodesById[node.data.id].position,
};
} else if (node.data.shape === 'group') {
if (node.data.shape === 'group') {
return {
...node,
position: { x, y },
@ -130,7 +136,7 @@ const layoutGroupChildren = (
const childSize = calcLabelSize(child.data.label);
child.position = {
x: groupNodeWidth / 2 - childSize.width / 2,
y: index * (childSize.height * 2 + space),
y: index * (childSize.height + space),
};
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { composeStories } from '@storybook/testing-react';
import { render } from '@testing-library/react';
import React from 'react';
import * as stories from './graph_layout.stories';
const { GraphLargeStackedEdgeCases } = composeStories(stories);
const TRANSLATE_XY_REGEX =
/translate\(\s*([+-]?\d+(\.\d+)?)(px|%)?\s*,\s*([+-]?\d+(\.\d+)?)(px|%)?\s*\)/;
interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
const getLabelRect = (el: HTMLElement): Rect | undefined => {
const match = el.style.transform.match(TRANSLATE_XY_REGEX);
if (!match || match.length < 5) {
return;
}
return {
left: Number(match[1]),
right: Number(match[1]) + 120,
top: Number(match[4]),
bottom: Number(match[4]) + 32,
};
};
const rectIntersect = (rect1: Rect, rect2: Rect) => {
return !(
rect1.top > rect2.bottom ||
rect1.right < rect2.left ||
rect1.bottom < rect2.top ||
rect1.left > rect2.right
);
};
describe('GraphLargeStackedEdgeCases story', () => {
it('all labels should be visible', async () => {
const { getAllByText } = render(<GraphLargeStackedEdgeCases />);
const labels = GraphLargeStackedEdgeCases.args?.nodes?.filter((node) => node.shape === 'label');
// With JSDOM toBeVisible can't check if elements are visually obscured by other overlapping elements
// This is a workaround which gives a rough estimation of a label's bounding rectangle and check for intersections
const labelsBoundingRect: Rect[] = [];
const labelElements: Set<string> = new Set();
for (const { label } of labels ?? []) {
// Get all label nodes that contains the label's text
const allLabelElements = getAllByText(
(_content, element) => element?.textContent === `${label!}`,
{
exact: true,
selector: 'div.react-flow__node-label',
}
);
expect(allLabelElements.length).toBeGreaterThan(0);
for (const labelElm of allLabelElements) {
const id = labelElm.getAttribute('data-id');
// Same label can appear more than once in the graph, so we skip them if already scanned
if (labelElements.has(id!)) {
continue;
}
labelElements.add(id!);
expect(labelElm).toBeVisible();
const labelRect = getLabelRect(labelElm);
expect(labelRect).not.toBeUndefined();
// Checks if current rect intersects with other labels
for (const currRect of labelsBoundingRect) {
expect(rectIntersect(currRect, labelRect!)).toBeFalsy();
}
labelsBoundingRect.push(labelRect!);
}
}
});
});

View file

@ -503,3 +503,31 @@ GraphStackedEdgeCases.args = {
},
]),
};
export const GraphLargeStackedEdgeCases = Template.bind({});
GraphLargeStackedEdgeCases.args = {
...extractEdges([
...baseGraph,
...Array(10)
.fill(0)
.map<EnhancedNodeViewModel>((_v, idx) => ({
id: 'a(oktauser)-b(hackeruser)',
source: 'oktauser',
target: 'hackeruser',
label: 'CreateUser' + idx,
color: 'primary',
shape: 'label',
})),
...Array(10)
.fill(0)
.map<EnhancedNodeViewModel>((_v, idx) => ({
id: 'a(siem-windows)-b(user)',
source: 'siem-windows',
target: 'user',
label: 'User login to OKTA' + idx,
color: 'danger',
shape: 'label',
})),
]),
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import babelJest from 'babel-jest';
// eslint-disable-next-line import/no-default-export
export default babelJest.createTransformer({
presets: [
[
require.resolve('@kbn/babel-preset/node_preset'),
{
'@babel/preset-env': {
// disable built-in filtering, which is more performant but strips the import of `regenerator-runtime` required by EUI
useBuiltIns: false,
corejs: false,
},
},
],
],
plugins: ['@emotion'],
});