[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',
brandUrl: 'https://github.com/elastic/kibana/tree/master/packages/kbn-storybook',
}),
showPanel: true,
showPanel: false,
isFullscreen: false,
panelPosition: 'bottom',
isToolshown: true,

View file

@ -9,8 +9,12 @@ import { storiesOf } from '@storybook/react';
import cytoscape from 'cytoscape';
import React from 'react';
import { Cytoscape } from './Cytoscape';
import { getCytoscapeElements } from './get_cytoscape_elements';
import serviceMapResponse from './cytoscape-layout-test-response.json';
import { iconForNode } from './icons';
const elementsFromResponses = getCytoscapeElements([serviceMapResponse], '');
storiesOf('app/ServiceMap/Cytoscape', module).add(
'example',
() => {
@ -49,11 +53,13 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
}
];
const height = 300;
const width = 1340;
const serviceName = 'opbeans-python';
return (
<Cytoscape
elements={elements}
height={height}
width={width}
serviceName={serviceName}
/>
);
@ -66,114 +72,137 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
}
);
storiesOf('app/ServiceMap/Cytoscape', module).add(
'node icons',
() => {
const cy = cytoscape();
const elements = [
{ data: { id: 'default', label: 'default', type: undefined } },
{ data: { id: 'cache', label: 'cache', type: 'cache' } },
{ data: { id: 'database', label: 'database', type: 'database' } },
{ data: { id: 'external', label: 'external', type: 'external' } },
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } },
storiesOf('app/ServiceMap/Cytoscape', module)
.add(
'node icons',
() => {
const cy = cytoscape();
const elements = [
{ data: { id: 'default', label: 'default', type: undefined } },
{ data: { id: 'cache', label: 'cache', type: 'cache' } },
{ data: { id: 'database', label: 'database', type: 'database' } },
{ data: { id: 'external', label: 'external', type: 'external' } },
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } },
{
data: {
id: 'dotnet',
label: 'dotnet service',
type: 'service',
agentName: 'dotnet'
}
},
{
data: {
id: 'go',
label: 'go service',
type: 'service',
agentName: 'go'
}
},
{
data: {
id: 'java',
label: 'java service',
type: 'service',
agentName: 'java'
}
},
{
data: {
id: 'js-base',
label: 'js-base service',
type: 'service',
agentName: 'js-base'
}
},
{
data: {
id: 'nodejs',
label: 'nodejs service',
type: 'service',
agentName: 'nodejs'
}
},
{
data: {
id: 'php',
label: 'php service',
type: 'service',
agentName: 'php'
}
},
{
data: {
id: 'python',
label: 'python service',
type: 'service',
agentName: 'python'
}
},
{
data: {
id: 'ruby',
label: 'ruby service',
type: 'service',
agentName: 'ruby'
{
data: {
id: 'dotnet',
label: 'dotnet service',
type: 'service',
agentName: 'dotnet'
}
},
{
data: {
id: 'go',
label: 'go service',
type: 'service',
agentName: 'go'
}
},
{
data: {
id: 'java',
label: 'java service',
type: 'service',
agentName: 'java'
}
},
{
data: {
id: 'js-base',
label: 'js-base service',
type: 'service',
agentName: 'js-base'
}
},
{
data: {
id: 'nodejs',
label: 'nodejs service',
type: 'service',
agentName: 'nodejs'
}
},
{
data: {
id: 'php',
label: 'php service',
type: 'service',
agentName: 'php'
}
},
{
data: {
id: 'python',
label: 'python service',
type: 'service',
agentName: 'python'
}
},
{
data: {
id: 'ruby',
label: 'ruby service',
type: 'service',
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,
useEffect,
ReactNode,
createContext
createContext,
useCallback
} from 'react';
import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre';
import { cytoscapeOptions } from './cytoscapeOptions';
cytoscape.use(dagre);
import { isRumAgentName } from '../../../../../../../plugins/apm/common/agent_name';
import {
cytoscapeOptions,
nodeHeight,
animationOptions
} from './cytoscapeOptions';
export const CytoscapeContext = createContext<cytoscape.Core | undefined>(
undefined
@ -26,6 +29,7 @@ interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
height: number;
width: number;
serviceName?: string;
style?: CSSProperties;
}
@ -52,19 +56,83 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
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({
children,
elements,
height,
width,
serviceName,
style
}: 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
// is required and can trigger rendering when changed.
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
useEffect(() => {
if (cy) {
@ -75,19 +143,6 @@ export function Cytoscape({
// Set up cytoscape event handlers
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 => {
event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover');
@ -99,18 +154,23 @@ export function Cytoscape({
if (cy) {
cy.on('data', dataHandler);
cy.ready(dataHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler);
cy.on('mouseout', 'edge, node', mouseoutHandler);
}
return () => {
if (cy) {
cy.removeListener('data', undefined, dataHandler);
cy.removeListener(
'data',
undefined,
dataHandler as cytoscape.EventHandler
);
cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
}
};
}, [cy, serviceName]);
}, [cy, dataHandler, serviceName]);
return (
<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';
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) {
return el.data('type') === 'service';
}
@ -79,7 +68,9 @@ const style: cytoscape.Stylesheet[] = [
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'curve-style': 'taxi',
// @ts-ignore
'taxi-direction': 'rightward',
'line-color': lineColor,
'overlay-opacity': 0,
'target-arrow-color': lineColor,
@ -103,13 +94,29 @@ const style: cytoscape.Stylesheet[] = [
'source-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 = {
autoungrabify: true,
boxSelectionEnabled: false,
layout,
maxZoom: 3,
minZoom: 0.2,
style

View file

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

View file

@ -3,18 +3,19 @@
* or more contributor license agreements. Licensed under 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';
export function useRefHeight(): [
MutableRefObject<HTMLDivElement | null>,
number
] {
export function useRefDimensions() {
const ref = useRef<HTMLDivElement>(null);
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",
"cronstrue": "^1.51.0",
"cytoscape": "^3.10.0",
"cytoscape-dagre": "^2.2.2",
"d3": "3.5.17",
"d3-scale": "1.0.7",
"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"
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:
version "3.10.0"
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.10.0.tgz#3b462e0d35121ecd2d2702f470915fd6dae01777"
@ -10967,14 +10960,6 @@ d@1:
dependencies:
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:
version "1.0.4"
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"
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:
version "4.1.16"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.16.tgz#82bb59643e30183cfb7b485ed4262a7b39d8a6c1"