[APM] Service Map Layout (#59020)

* Addresses #55544.
- uses the core breadthfirst cytoscape layout
- rotates elements by -90degrees
- selects rum nodes as roots
- implements hover styles to show connected nodes
- fixes flash of unstyled cytoscape elements on initial load

* PR review feedback

* adds canned response for testing cytoscape layout in storybook

* update dep snapshot for removing cytoscape-dagre
This commit is contained in:
Oliver Gupte 2020-03-03 18:26:28 -08:00 committed by GitHub
parent b12ef02cc4
commit 5539d6955f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 251 additions and 182 deletions

View file

@ -55,7 +55,7 @@ addParameters({
brandTitle: 'Kibana Storybook', brandTitle: 'Kibana Storybook',
brandUrl: 'https://github.com/elastic/kibana/tree/master/packages/kbn-storybook', brandUrl: 'https://github.com/elastic/kibana/tree/master/packages/kbn-storybook',
}), }),
showPanel: true, showPanel: false,
isFullscreen: false, isFullscreen: false,
panelPosition: 'bottom', panelPosition: 'bottom',
isToolshown: true, isToolshown: true,

View file

@ -9,8 +9,12 @@ import { storiesOf } from '@storybook/react';
import cytoscape from 'cytoscape'; import cytoscape from 'cytoscape';
import React from 'react'; import React from 'react';
import { Cytoscape } from './Cytoscape'; import { Cytoscape } from './Cytoscape';
import { getCytoscapeElements } from './get_cytoscape_elements';
import serviceMapResponse from './cytoscape-layout-test-response.json';
import { iconForNode } from './icons'; import { iconForNode } from './icons';
const elementsFromResponses = getCytoscapeElements([serviceMapResponse], '');
storiesOf('app/ServiceMap/Cytoscape', module).add( storiesOf('app/ServiceMap/Cytoscape', module).add(
'example', 'example',
() => { () => {
@ -49,11 +53,13 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
} }
]; ];
const height = 300; const height = 300;
const width = 1340;
const serviceName = 'opbeans-python'; const serviceName = 'opbeans-python';
return ( return (
<Cytoscape <Cytoscape
elements={elements} elements={elements}
height={height} height={height}
width={width}
serviceName={serviceName} serviceName={serviceName}
/> />
); );
@ -66,114 +72,137 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
} }
); );
storiesOf('app/ServiceMap/Cytoscape', module).add( storiesOf('app/ServiceMap/Cytoscape', module)
'node icons', .add(
() => { 'node icons',
const cy = cytoscape(); () => {
const elements = [ const cy = cytoscape();
{ data: { id: 'default', label: 'default', type: undefined } }, const elements = [
{ data: { id: 'cache', label: 'cache', type: 'cache' } }, { data: { id: 'default', label: 'default', type: undefined } },
{ data: { id: 'database', label: 'database', type: 'database' } }, { data: { id: 'cache', label: 'cache', type: 'cache' } },
{ data: { id: 'external', label: 'external', type: 'external' } }, { data: { id: 'database', label: 'database', type: 'database' } },
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } }, { data: { id: 'external', label: 'external', type: 'external' } },
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } },
{ {
data: { data: {
id: 'dotnet', id: 'dotnet',
label: 'dotnet service', label: 'dotnet service',
type: 'service', type: 'service',
agentName: 'dotnet' agentName: 'dotnet'
} }
}, },
{ {
data: { data: {
id: 'go', id: 'go',
label: 'go service', label: 'go service',
type: 'service', type: 'service',
agentName: 'go' agentName: 'go'
} }
}, },
{ {
data: { data: {
id: 'java', id: 'java',
label: 'java service', label: 'java service',
type: 'service', type: 'service',
agentName: 'java' agentName: 'java'
} }
}, },
{ {
data: { data: {
id: 'js-base', id: 'js-base',
label: 'js-base service', label: 'js-base service',
type: 'service', type: 'service',
agentName: 'js-base' agentName: 'js-base'
} }
}, },
{ {
data: { data: {
id: 'nodejs', id: 'nodejs',
label: 'nodejs service', label: 'nodejs service',
type: 'service', type: 'service',
agentName: 'nodejs' agentName: 'nodejs'
} }
}, },
{ {
data: { data: {
id: 'php', id: 'php',
label: 'php service', label: 'php service',
type: 'service', type: 'service',
agentName: 'php' agentName: 'php'
} }
}, },
{ {
data: { data: {
id: 'python', id: 'python',
label: 'python service', label: 'python service',
type: 'service', type: 'service',
agentName: 'python' agentName: 'python'
} }
}, },
{ {
data: { data: {
id: 'ruby', id: 'ruby',
label: 'ruby service', label: 'ruby service',
type: 'service', type: 'service',
agentName: 'ruby' agentName: 'ruby'
}
} }
];
cy.add(elements);
return (
<EuiFlexGroup gutterSize="l" wrap={true}>
{cy.nodes().map(node => (
<EuiFlexItem key={node.data('id')}>
<EuiCard
description={
<pre>
agentName: {node.data('agentName') || 'undefined'}, type:{' '}
{node.data('type') || 'undefined'}
</pre>
}
icon={
<img
alt={node.data('label')}
src={iconForNode(node)}
height={80}
width={80}
/>
}
title={node.data('label')}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
},
{
info: {
propTables: false,
source: false
} }
];
cy.add(elements);
return (
<EuiFlexGroup gutterSize="l" wrap={true}>
{cy.nodes().map(node => (
<EuiFlexItem key={node.data('id')}>
<EuiCard
description={
<pre>
agentName: {node.data('agentName') || 'undefined'}, type:{' '}
{node.data('type') || 'undefined'}
</pre>
}
icon={
<img
alt={node.data('label')}
src={iconForNode(node)}
height={80}
width={80}
/>
}
title={node.data('label')}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
},
{
info: {
propTables: false,
source: false
} }
} )
); .add(
'layout',
() => {
const height = 640;
const width = 1340;
const serviceName = undefined; // global service map
return (
<Cytoscape
elements={elementsFromResponses}
height={height}
width={width}
serviceName={serviceName}
/>
);
},
{
info: {
source: false
}
}
)
.addParameters({ options: { showPanel: false } });

View file

@ -10,13 +10,16 @@ import React, {
useRef, useRef,
useEffect, useEffect,
ReactNode, ReactNode,
createContext createContext,
useCallback
} from 'react'; } from 'react';
import cytoscape from 'cytoscape'; import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre'; import { isRumAgentName } from '../../../../../../../plugins/apm/common/agent_name';
import { cytoscapeOptions } from './cytoscapeOptions'; import {
cytoscapeOptions,
cytoscape.use(dagre); nodeHeight,
animationOptions
} from './cytoscapeOptions';
export const CytoscapeContext = createContext<cytoscape.Core | undefined>( export const CytoscapeContext = createContext<cytoscape.Core | undefined>(
undefined undefined
@ -26,6 +29,7 @@ interface CytoscapeProps {
children?: ReactNode; children?: ReactNode;
elements: cytoscape.ElementDefinition[]; elements: cytoscape.ElementDefinition[];
height: number; height: number;
width: number;
serviceName?: string; serviceName?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@ -52,19 +56,83 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined]; return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
} }
function getLayoutOptions(
selectedRoots: string[],
height: number,
width: number
): cytoscape.LayoutOptions {
return {
name: 'breadthfirst',
roots: selectedRoots,
fit: true,
padding: nodeHeight,
spacingFactor: 0.85,
animate: true,
animationEasing: animationOptions.easing,
animationDuration: animationOptions.duration,
// Rotate nodes from top -> bottom to display left -> right
// @ts-ignore
transform: (node: any, { x, y }: cytoscape.Position) => ({ x: y, y: -x }),
// swap width/height of boundingBox to compensation for the rotation
boundingBox: { x1: 0, y1: 0, w: height, h: width }
};
}
function selectRoots(elements: cytoscape.ElementDefinition[]): string[] {
const nodes = cytoscape({ elements }).nodes();
const unconnectedNodes = nodes.roots().intersection(nodes.leaves());
const rumNodes = nodes.filter(node => isRumAgentName(node.data('agentName')));
return rumNodes.union(unconnectedNodes).map(node => node.id());
}
export function Cytoscape({ export function Cytoscape({
children, children,
elements, elements,
height, height,
width,
serviceName, serviceName,
style style
}: CytoscapeProps) { }: CytoscapeProps) {
const [ref, cy] = useCytoscape({ ...cytoscapeOptions, elements }); const initialElements = elements.map(element => ({
...element,
// prevents flash of unstyled elements
classes: [element.classes, 'invisible'].join(' ').trim()
}));
const [ref, cy] = useCytoscape({
...cytoscapeOptions,
elements: initialElements
});
// Add the height to the div style. The height is a separate prop because it // Add the height to the div style. The height is a separate prop because it
// is required and can trigger rendering when changed. // is required and can trigger rendering when changed.
const divStyle = { ...style, height }; const divStyle = { ...style, height };
const dataHandler = useCallback<cytoscape.EventHandler>(
event => {
if (cy) {
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0 && serviceName) {
cy.nodes().removeClass('primary');
cy.getElementById(serviceName).addClass('primary');
}
if (event.cy.elements().length > 0) {
const selectedRoots = selectRoots(elements);
const layout = cy.layout(
getLayoutOptions(selectedRoots, height, width)
);
layout.one('layoutstop', () => {
// show elements after layout is applied
cy.elements().removeClass('invisible');
});
layout.run();
}
}
},
[cy, serviceName, elements, height, width]
);
// Trigger a custom "data" event when data changes // Trigger a custom "data" event when data changes
useEffect(() => { useEffect(() => {
if (cy) { if (cy) {
@ -75,19 +143,6 @@ export function Cytoscape({
// Set up cytoscape event handlers // Set up cytoscape event handlers
useEffect(() => { useEffect(() => {
const dataHandler: cytoscape.EventHandler = event => {
if (cy) {
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0 && serviceName) {
cy.nodes().removeClass('primary');
cy.getElementById(serviceName).addClass('primary');
}
if (event.cy.elements().length > 0) {
cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run();
}
}
};
const mouseoverHandler: cytoscape.EventHandler = event => { const mouseoverHandler: cytoscape.EventHandler = event => {
event.target.addClass('hover'); event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover'); event.target.connectedEdges().addClass('nodeHover');
@ -99,18 +154,23 @@ export function Cytoscape({
if (cy) { if (cy) {
cy.on('data', dataHandler); cy.on('data', dataHandler);
cy.ready(dataHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler); cy.on('mouseover', 'edge, node', mouseoverHandler);
cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('mouseout', 'edge, node', mouseoutHandler);
} }
return () => { return () => {
if (cy) { if (cy) {
cy.removeListener('data', undefined, dataHandler); cy.removeListener(
'data',
undefined,
dataHandler as cytoscape.EventHandler
);
cy.removeListener('mouseover', 'edge, node', mouseoverHandler); cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
cy.removeListener('mouseout', 'edge, node', mouseoutHandler); cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
} }
}; };
}, [cy, serviceName]); }, [cy, dataHandler, serviceName]);
return ( return (
<CytoscapeContext.Provider value={cy}> <CytoscapeContext.Provider value={cy}>

File diff suppressed because one or more lines are too long

View file

@ -15,17 +15,6 @@ export const animationOptions: cytoscape.AnimationOptions = {
const lineColor = '#C5CCD7'; const lineColor = '#C5CCD7';
export const nodeHeight = parseInt(theme.avatarSizing.l.size, 10); export const nodeHeight = parseInt(theme.avatarSizing.l.size, 10);
const layout = {
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankDir: 'LR',
animate: true,
animationEasing: animationOptions.easing,
animationDuration: animationOptions.duration,
fit: true,
padding: nodeHeight
};
function isService(el: cytoscape.NodeSingular) { function isService(el: cytoscape.NodeSingular) {
return el.data('type') === 'service'; return el.data('type') === 'service';
} }
@ -79,7 +68,9 @@ const style: cytoscape.Stylesheet[] = [
{ {
selector: 'edge', selector: 'edge',
style: { style: {
'curve-style': 'bezier', 'curve-style': 'taxi',
// @ts-ignore
'taxi-direction': 'rightward',
'line-color': lineColor, 'line-color': lineColor,
'overlay-opacity': 0, 'overlay-opacity': 0,
'target-arrow-color': lineColor, 'target-arrow-color': lineColor,
@ -103,13 +94,29 @@ const style: cytoscape.Stylesheet[] = [
'source-distance-from-node': theme.paddingSizes.xs, 'source-distance-from-node': theme.paddingSizes.xs,
'target-distance-from-node': theme.paddingSizes.xs 'target-distance-from-node': theme.paddingSizes.xs
} }
},
// @ts-ignore
{
selector: '.invisible',
style: { visibility: 'hidden' }
},
{
selector: 'edge.nodeHover',
style: {
width: 4
}
},
{
selector: 'node.hover',
style: {
'border-width': 4
}
} }
]; ];
export const cytoscapeOptions: cytoscape.CytoscapeOptions = { export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
autoungrabify: true, autoungrabify: true,
boxSelectionEnabled: false, boxSelectionEnabled: false,
layout,
maxZoom: 3, maxZoom: 3,
minZoom: 0.2, minZoom: 0.2,
style style

View file

@ -32,7 +32,7 @@ import { Cytoscape } from './Cytoscape';
import { getCytoscapeElements } from './get_cytoscape_elements'; import { getCytoscapeElements } from './get_cytoscape_elements';
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
import { Popover } from './Popover'; import { Popover } from './Popover';
import { useRefHeight } from './useRefHeight'; import { useRefDimensions } from './useRefDimensions';
interface ServiceMapProps { interface ServiceMapProps {
serviceName?: string; serviceName?: string;
@ -196,7 +196,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [elements]); }, [elements]);
const [wrapperRef, height] = useRefHeight(); const { ref: wrapperRef, width, height } = useRefDimensions();
if (!license) { if (!license) {
return null; return null;
@ -211,6 +211,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
elements={renderedElements.current} elements={renderedElements.current}
serviceName={serviceName} serviceName={serviceName}
height={height} height={height}
width={width}
style={cytoscapeDivStyle} style={cytoscapeDivStyle}
> >
<Controls /> <Controls />

View file

@ -3,18 +3,19 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { MutableRefObject, useRef } from 'react'; import { useRef } from 'react';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
export function useRefHeight(): [ export function useRefDimensions() {
MutableRefObject<HTMLDivElement | null>,
number
] {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const windowHeight = useWindowSize().height; const windowHeight = useWindowSize().height;
const topOffset = ref.current?.getBoundingClientRect()?.top ?? 0;
const height = ref.current ? windowHeight - topOffset : 0; if (!ref.current) {
return { ref, width: 0, height: 0 };
}
return [ref, height]; const { top, width } = ref.current.getBoundingClientRect();
const height = windowHeight - top;
return { ref, width, height };
} }

View file

@ -222,7 +222,6 @@
"copy-to-clipboard": "^3.0.8", "copy-to-clipboard": "^3.0.8",
"cronstrue": "^1.51.0", "cronstrue": "^1.51.0",
"cytoscape": "^3.10.0", "cytoscape": "^3.10.0",
"cytoscape-dagre": "^2.2.2",
"d3": "3.5.17", "d3": "3.5.17",
"d3-scale": "1.0.7", "d3-scale": "1.0.7",
"dedent": "^0.7.0", "dedent": "^0.7.0",

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
declare module 'cytoscape-dagre';

View file

@ -10695,13 +10695,6 @@ cypress@^4.0.2:
url "0.11.0" url "0.11.0"
yauzl "2.10.0" yauzl "2.10.0"
cytoscape-dagre@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/cytoscape-dagre/-/cytoscape-dagre-2.2.2.tgz#5f32a85c0ba835f167efee531df9e89ac58ff411"
integrity sha512-zsg36qNwua/L2stJSWkcbSDcvW3E6VZf6KRe6aLnQJxuXuz89tMqI5EVYVKEcNBgzTEzFMFv0PE3T0nD4m6VDw==
dependencies:
dagre "^0.8.2"
cytoscape@^3.10.0: cytoscape@^3.10.0:
version "3.10.0" version "3.10.0"
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.10.0.tgz#3b462e0d35121ecd2d2702f470915fd6dae01777" resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.10.0.tgz#3b462e0d35121ecd2d2702f470915fd6dae01777"
@ -10967,14 +10960,6 @@ d@1:
dependencies: dependencies:
es5-ext "^0.10.9" es5-ext "^0.10.9"
dagre@^0.8.2:
version "0.8.4"
resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.4.tgz#26b9fb8f7bdc60c6110a0458c375261836786061"
integrity sha512-Dj0csFDrWYKdavwROb9FccHfTC4fJbyF/oJdL9LNZJ8WUvl968P6PAKEriGqfbdArVJEmmfA+UyumgWEwcHU6A==
dependencies:
graphlib "^2.1.7"
lodash "^4.17.4"
damerau-levenshtein@^1.0.4: damerau-levenshtein@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514"
@ -15304,13 +15289,6 @@ graceful-fs@~1.1:
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
graphlib@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.7.tgz#b6a69f9f44bd9de3963ce6804a2fc9e73d86aecc"
integrity sha512-TyI9jIy2J4j0qgPmOOrHTCtpPqJGN/aurBwc6ZT+bRii+di1I+Wv3obRhVrmBEXet+qkMaEX67dXrwsd3QQM6w==
dependencies:
lodash "^4.17.5"
graphql-anywhere@^4.1.0-alpha.0: graphql-anywhere@^4.1.0-alpha.0:
version "4.1.16" version "4.1.16"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.16.tgz#82bb59643e30183cfb7b485ed4262a7b39d8a6c1" resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.16.tgz#82bb59643e30183cfb7b485ed4262a7b39d8a6c1"