From d405da9b2cba585261c5bb47594df824eed9aca6 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Thu, 3 Sep 2020 01:45:32 -0700 Subject: [PATCH] [APM] Service maps layout enhancements (#76481) (#76594) * Fixes storybook anomaly score generation and better utilizes available screen space * Closes #71770 for APM service maps by replacing breadthfirst layout with one from the cytoscape-dagre extension. Also replaces the taxi edges with cubic bezier edges. Finally, this adds the ability to drag individual nodes around the service map. * Removes unused code * removes commented line of code * - Adds ability for scripts/notice.js to check files with the .tsx file extension - Adds attribution for `applyCubicBezierStyles` * Refine comment text and MIT license url --- NOTICE.txt | 7 ++ src/dev/notice/generate_notice_from_source.ts | 2 +- x-pack/package.json | 1 + .../components/app/ServiceMap/Cytoscape.tsx | 98 ++++++++----------- .../app/ServiceMap/Popover/index.tsx | 2 + .../__stories__/Cytoscape.stories.tsx | 4 +- .../CytoscapeExampleData.stories.tsx | 25 +++-- .../generate_service_map_elements.ts | 25 +++-- .../app/ServiceMap/cytoscapeOptions.ts | 5 +- .../components/app/ServiceMap/index.tsx | 3 +- .../plugins/apm/typings/cytoscape_dagre.d.ts | 7 ++ yarn.lock | 22 +++++ 12 files changed, 113 insertions(+), 88 deletions(-) create mode 100644 x-pack/plugins/apm/typings/cytoscape_dagre.d.ts diff --git a/NOTICE.txt b/NOTICE.txt index e1552852d03493..d689abf4c4e05c 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -281,6 +281,13 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +--- +This product includes code in the function applyCubicBezierStyles that was +inspired by a public Codepen, which was available under a "MIT" license. + +Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO) +MIT License http://www.opensource.org/licenses/mit-license + --- This product includes code that is adapted from mapbox-gl-js, which is available under a "BSD-3-Clause" license. diff --git a/src/dev/notice/generate_notice_from_source.ts b/src/dev/notice/generate_notice_from_source.ts index 0bef5bc5f32d42..9f7eb9d9e1aa4b 100644 --- a/src/dev/notice/generate_notice_from_source.ts +++ b/src/dev/notice/generate_notice_from_source.ts @@ -41,7 +41,7 @@ interface Options { * into the repository. */ export async function generateNoticeFromSource({ productName, directory, log }: Options) { - const globs = ['**/*.{js,less,css,ts}']; + const globs = ['**/*.{js,less,css,ts,tsx}']; const options = { cwd: directory, diff --git a/x-pack/package.json b/x-pack/package.json index 64ea16ce8aa1a1..c8b9576c3ff278 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -305,6 +305,7 @@ "concat-stream": "1.6.2", "content-disposition": "0.5.3", "cytoscape": "^3.10.0", + "cytoscape-dagre": "^2.2.2", "d3-array": "1.2.4", "dedent": "^0.7.0", "del": "^5.1.0", diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 1cde473aae6fa6..0b00c8a8bf0934 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -13,6 +13,7 @@ import React, { useState, } from 'react'; import cytoscape from 'cytoscape'; +import dagre from 'cytoscape-dagre'; import { debounce } from 'lodash'; import { useTheme } from '../../../hooks/useTheme'; import { @@ -22,6 +23,8 @@ import { } from './cytoscapeOptions'; import { useUiTracker } from '../../../../../observability/public'; +cytoscape.use(dagre); + export const CytoscapeContext = createContext( undefined ); @@ -30,7 +33,6 @@ interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; height: number; - width: number; serviceName?: string; style?: CSSProperties; } @@ -57,59 +59,52 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) { return [ref, cy] as [React.MutableRefObject, cytoscape.Core | undefined]; } -function rotatePoint( - { x, y }: { x: number; y: number }, - degreesRotated: number -) { - const radiansPerDegree = Math.PI / 180; - const θ = radiansPerDegree * degreesRotated; - const cosθ = Math.cos(θ); - const sinθ = Math.sin(θ); - return { - x: x * cosθ - y * sinθ, - y: x * sinθ + y * cosθ, - }; -} - -function getLayoutOptions( - selectedRoots: string[], - height: number, - width: number, - nodeHeight: number -): cytoscape.LayoutOptions { +function getLayoutOptions(nodeHeight: number): cytoscape.LayoutOptions { return { - name: 'breadthfirst', - // @ts-ignore DefinitelyTyped is incorrect here. Roots can be an Array - roots: selectedRoots.length ? selectedRoots : undefined, + name: 'dagre', fit: true, padding: nodeHeight, spacingFactor: 1.2, // @ts-ignore - // Rotate nodes counter-clockwise to transform layout from top→bottom to left→right. - // The extra 5° achieves the effect of separating overlapping taxi-styled edges. - transform: (node: any, pos: cytoscape.Position) => rotatePoint(pos, -95), - // swap width/height of boundingBox to compensate for the rotation - boundingBox: { x1: 0, y1: 0, w: height, h: width }, + nodeSep: nodeHeight, + edgeSep: 32, + rankSep: 128, + rankDir: 'LR', + ranker: 'network-simplex', }; } -function selectRoots(cy: cytoscape.Core): string[] { - const bfs = cy.elements().bfs({ - roots: cy.elements().leaves(), +/* + * @notice + * This product includes code in the function applyCubicBezierStyles that was + * inspired by a public Codepen, which was available under a "MIT" license. + * + * Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO) + * MIT License http://www.opensource.org/licenses/mit-license + */ +function applyCubicBezierStyles(edges: cytoscape.EdgeCollection) { + edges.forEach((edge) => { + const { x: x0, y: y0 } = edge.source().position(); + const { x: x1, y: y1 } = edge.target().position(); + const x = x1 - x0; + const y = y1 - y0; + const z = Math.sqrt(x * x + y * y); + const costheta = z === 0 ? 0 : x / z; + const alpha = 0.25; + // Two values for control-point-distances represent a pair symmetric quadratic + // bezier curves joined in the middle as a seamless cubic bezier curve: + edge.style('control-point-distances', [ + -alpha * y * costheta, + alpha * y * costheta, + ]); + edge.style('control-point-weights', [alpha, 1 - alpha]); }); - const furthestNodeFromLeaves = bfs.path.last(); - return cy - .elements() - .roots() - .union(furthestNodeFromLeaves) - .map((el) => el.id()); } export function Cytoscape({ children, elements, height, - width, serviceName, style, }: CytoscapeProps) { @@ -151,13 +146,7 @@ export function Cytoscape({ } else { resetConnectedEdgeStyle(); } - - const selectedRoots = selectRoots(event.cy); - const layout = cy.layout( - getLayoutOptions(selectedRoots, height, width, nodeHeight) - ); - - layout.run(); + cy.layout(getLayoutOptions(nodeHeight)).run(); } }; let layoutstopDelayTimeout: NodeJS.Timeout; @@ -180,6 +169,7 @@ export function Cytoscape({ event.cy.fit(undefined, nodeHeight); } }, 0); + applyCubicBezierStyles(event.cy.edges()); }; // debounce hover tracking so it doesn't spam telemetry with redundant events const trackNodeEdgeHover = debounce( @@ -211,6 +201,9 @@ export function Cytoscape({ console.debug('cytoscape:', event); } }; + const dragHandler: cytoscape.EventHandler = (event) => { + applyCubicBezierStyles(event.target.connectedEdges()); + }; if (cy) { cy.on('data layoutstop select unselect', debugHandler); @@ -220,6 +213,7 @@ export function Cytoscape({ cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('select', 'node', selectHandler); cy.on('unselect', 'node', unselectHandler); + cy.on('drag', 'node', dragHandler); cy.remove(cy.elements()); cy.add(elements); @@ -239,19 +233,11 @@ export function Cytoscape({ cy.removeListener('mouseout', 'edge, node', mouseoutHandler); cy.removeListener('select', 'node', selectHandler); cy.removeListener('unselect', 'node', unselectHandler); + cy.removeListener('drag', 'node', dragHandler); } clearTimeout(layoutstopDelayTimeout); }; - }, [ - cy, - elements, - height, - serviceName, - trackApmEvent, - width, - nodeHeight, - theme, - ]); + }, [cy, elements, height, serviceName, trackApmEvent, nodeHeight, theme]); return ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index 1658c36e8a92fb..8291d94d91c48f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -71,6 +71,7 @@ export function Popover({ focusedServiceName }: PopoverProps) { cy.on('select', 'node', selectHandler); cy.on('unselect', 'node', deselect); cy.on('data viewport', deselect); + cy.on('drag', 'node', deselect); } return () => { @@ -78,6 +79,7 @@ export function Popover({ focusedServiceName }: PopoverProps) { cy.removeListener('select', 'node', selectHandler); cy.removeListener('unselect', 'node', deselect); cy.removeListener('data viewport', undefined, deselect); + cy.removeListener('drag', 'node', deselect); } }; }, [cy, deselect]); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx index 2a7d11bb57ca5b..5b50eb953d896e 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx @@ -49,13 +49,11 @@ storiesOf('app/ServiceMap/Cytoscape', module) }, ]; const height = 300; - const width = 1340; const serviceName = 'opbeans-python'; return ( ); @@ -330,7 +328,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) }, }, ]; - return ; + return ; }, { info: { propTables: false, source: false }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx index 830e3719b11f9d..d8dcc71f5051d9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx @@ -35,6 +35,8 @@ function setSessionJson(json: string) { window.sessionStorage.setItem(SESSION_STORAGE_KEY, json); } +const getCytoscapeHeight = () => window.innerHeight - 300; + storiesOf(STORYBOOK_PATH, module) .addDecorator((storyFn) => {storyFn()}) .add( @@ -43,16 +45,17 @@ storiesOf(STORYBOOK_PATH, module) const [size, setSize] = useState(10); const [json, setJson] = useState(''); const [elements, setElements] = useState( - generateServiceMapElements(size) + generateServiceMapElements({ size, hasAnomalies: true }) ); - return (
{ - setElements(generateServiceMapElements(size)); + setElements( + generateServiceMapElements({ size, hasAnomalies: true }) + ); setJson(''); }} > @@ -79,7 +82,7 @@ storiesOf(STORYBOOK_PATH, module) - + {json && ( - + @@ -204,8 +207,7 @@ storiesOf(STORYBOOK_PATH, module)
); @@ -224,8 +226,7 @@ storiesOf(STORYBOOK_PATH, module)
); @@ -244,8 +245,7 @@ storiesOf(STORYBOOK_PATH, module)
); @@ -264,8 +264,7 @@ storiesOf(STORYBOOK_PATH, module)
); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts index 012256db3ab987..57ef2d49291c40 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getSeverity } from '../Popover/getSeverity'; - -export function generateServiceMapElements(size: number): any[] { +export function generateServiceMapElements({ + size, + hasAnomalies, +}: { + size: number; + hasAnomalies: boolean; +}): any[] { const services = range(size).map((i) => { const name = getName(); const anomalyScore = randn(101); @@ -15,11 +19,14 @@ export function generateServiceMapElements(size: number): any[] { 'service.environment': 'production', 'service.name': name, 'agent.name': getAgentName(), - anomaly_score: anomalyScore, - anomaly_severity: getSeverity(anomalyScore), - actual_value: Math.random() * 2000000, - typical_value: Math.random() * 1000000, - ml_job_id: `${name}-request-high_mean_response_time`, + serviceAnomalyStats: hasAnomalies + ? { + transactionType: 'request', + anomalyScore, + actualValue: Math.random() * 2000000, + jobId: `${name}-request-high_mean_response_time`, + } + : undefined, }; }); @@ -146,7 +153,7 @@ const NAMES = [ 'leech', 'loki', 'longshot', - 'lumpkin,', + 'lumpkin', 'madame-web', 'magician', 'magneto', diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 4a271019e06db9..9d58ed142dab79 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -168,9 +168,7 @@ const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => { { selector: 'edge', style: { - 'curve-style': 'taxi', - // @ts-ignore - 'taxi-direction': 'auto', + 'curve-style': 'unbundled-bezier', 'line-color': lineColor, 'overlay-opacity': 0, 'target-arrow-color': lineColor, @@ -264,7 +262,6 @@ ${theme.eui.euiColorLightShade}`, export const getCytoscapeOptions = ( theme: EuiTheme ): cytoscape.CytoscapeOptions => ({ - autoungrabify: true, boxSelectionEnabled: false, maxZoom: 3, minZoom: 0.2, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index d4be4da2ae1c54..83fab95bc91c9b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -57,7 +57,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { } }, [license, serviceName, urlParams]); - const { ref, height, width } = useRefDimensions(); + const { ref, height } = useRefDimensions(); useTrackPageview({ app: 'apm', path: 'service_map' }); useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); @@ -78,7 +78,6 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { height={height} serviceName={serviceName} style={getCytoscapeDivStyle(theme)} - width={width} > diff --git a/x-pack/plugins/apm/typings/cytoscape_dagre.d.ts b/x-pack/plugins/apm/typings/cytoscape_dagre.d.ts new file mode 100644 index 00000000000000..b5bbdfc14d9d35 --- /dev/null +++ b/x-pack/plugins/apm/typings/cytoscape_dagre.d.ts @@ -0,0 +1,7 @@ +/* + * 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'; diff --git a/yarn.lock b/yarn.lock index e8a73786de2996..a9e4824f238dad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10035,6 +10035,13 @@ cypress@4.11.0: 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" @@ -10247,6 +10254,14 @@ d@1: dependencies: es5-ext "^0.10.9" +dagre@^0.8.2: + version "0.8.5" + resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" + integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw== + dependencies: + graphlib "^2.1.8" + lodash "^4.17.15" + damerau-levenshtein@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" @@ -14390,6 +14405,13 @@ graceful-fs@~1.1: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-1.1.14.tgz#07078db5f6377f6321fceaaedf497de124dc9465" integrity sha1-BweNtfY3f2Mh/Oqu30l94STclGU= +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + 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"