Skip to content

Commit d3416c0

Browse files
authored
[APM] Service maps layout enhancements (#76481)
* 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
1 parent 2f017b0 commit d3416c0

File tree

12 files changed

+113
-88
lines changed

12 files changed

+113
-88
lines changed

NOTICE.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
281281
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
282282
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
283283

284+
---
285+
This product includes code in the function applyCubicBezierStyles that was
286+
inspired by a public Codepen, which was available under a "MIT" license.
287+
288+
Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO)
289+
MIT License http://www.opensource.org/licenses/mit-license
290+
284291
---
285292
This product includes code that is adapted from mapbox-gl-js, which is
286293
available under a "BSD-3-Clause" license.

src/dev/notice/generate_notice_from_source.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface Options {
4141
* into the repository.
4242
*/
4343
export async function generateNoticeFromSource({ productName, directory, log }: Options) {
44-
const globs = ['**/*.{js,less,css,ts}'];
44+
const globs = ['**/*.{js,less,css,ts,tsx}'];
4545

4646
const options = {
4747
cwd: directory,

x-pack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@
305305
"concat-stream": "1.6.2",
306306
"content-disposition": "0.5.3",
307307
"cytoscape": "^3.10.0",
308+
"cytoscape-dagre": "^2.2.2",
308309
"d3-array": "1.2.4",
309310
"dedent": "^0.7.0",
310311
"del": "^5.1.0",

x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx

Lines changed: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import React, {
1313
useState,
1414
} from 'react';
1515
import cytoscape from 'cytoscape';
16+
import dagre from 'cytoscape-dagre';
1617
import { debounce } from 'lodash';
1718
import { useTheme } from '../../../hooks/useTheme';
1819
import {
@@ -22,6 +23,8 @@ import {
2223
} from './cytoscapeOptions';
2324
import { useUiTracker } from '../../../../../observability/public';
2425

26+
cytoscape.use(dagre);
27+
2528
export const CytoscapeContext = createContext<cytoscape.Core | undefined>(
2629
undefined
2730
);
@@ -30,7 +33,6 @@ interface CytoscapeProps {
3033
children?: ReactNode;
3134
elements: cytoscape.ElementDefinition[];
3235
height: number;
33-
width: number;
3436
serviceName?: string;
3537
style?: CSSProperties;
3638
}
@@ -57,59 +59,52 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
5759
return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
5860
}
5961

60-
function rotatePoint(
61-
{ x, y }: { x: number; y: number },
62-
degreesRotated: number
63-
) {
64-
const radiansPerDegree = Math.PI / 180;
65-
const θ = radiansPerDegree * degreesRotated;
66-
const cosθ = Math.cos(θ);
67-
const sinθ = Math.sin(θ);
68-
return {
69-
x: x * cosθ - y * sinθ,
70-
y: x * sinθ + y * cosθ,
71-
};
72-
}
73-
74-
function getLayoutOptions(
75-
selectedRoots: string[],
76-
height: number,
77-
width: number,
78-
nodeHeight: number
79-
): cytoscape.LayoutOptions {
62+
function getLayoutOptions(nodeHeight: number): cytoscape.LayoutOptions {
8063
return {
81-
name: 'breadthfirst',
82-
// @ts-ignore DefinitelyTyped is incorrect here. Roots can be an Array
83-
roots: selectedRoots.length ? selectedRoots : undefined,
64+
name: 'dagre',
8465
fit: true,
8566
padding: nodeHeight,
8667
spacingFactor: 1.2,
8768
// @ts-ignore
88-
// Rotate nodes counter-clockwise to transform layout from top→bottom to left→right.
89-
// The extra 5° achieves the effect of separating overlapping taxi-styled edges.
90-
transform: (node: any, pos: cytoscape.Position) => rotatePoint(pos, -95),
91-
// swap width/height of boundingBox to compensate for the rotation
92-
boundingBox: { x1: 0, y1: 0, w: height, h: width },
69+
nodeSep: nodeHeight,
70+
edgeSep: 32,
71+
rankSep: 128,
72+
rankDir: 'LR',
73+
ranker: 'network-simplex',
9374
};
9475
}
9576

96-
function selectRoots(cy: cytoscape.Core): string[] {
97-
const bfs = cy.elements().bfs({
98-
roots: cy.elements().leaves(),
77+
/*
78+
* @notice
79+
* This product includes code in the function applyCubicBezierStyles that was
80+
* inspired by a public Codepen, which was available under a "MIT" license.
81+
*
82+
* Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO)
83+
* MIT License http://www.opensource.org/licenses/mit-license
84+
*/
85+
function applyCubicBezierStyles(edges: cytoscape.EdgeCollection) {
86+
edges.forEach((edge) => {
87+
const { x: x0, y: y0 } = edge.source().position();
88+
const { x: x1, y: y1 } = edge.target().position();
89+
const x = x1 - x0;
90+
const y = y1 - y0;
91+
const z = Math.sqrt(x * x + y * y);
92+
const costheta = z === 0 ? 0 : x / z;
93+
const alpha = 0.25;
94+
// Two values for control-point-distances represent a pair symmetric quadratic
95+
// bezier curves joined in the middle as a seamless cubic bezier curve:
96+
edge.style('control-point-distances', [
97+
-alpha * y * costheta,
98+
alpha * y * costheta,
99+
]);
100+
edge.style('control-point-weights', [alpha, 1 - alpha]);
99101
});
100-
const furthestNodeFromLeaves = bfs.path.last();
101-
return cy
102-
.elements()
103-
.roots()
104-
.union(furthestNodeFromLeaves)
105-
.map((el) => el.id());
106102
}
107103

108104
export function Cytoscape({
109105
children,
110106
elements,
111107
height,
112-
width,
113108
serviceName,
114109
style,
115110
}: CytoscapeProps) {
@@ -151,13 +146,7 @@ export function Cytoscape({
151146
} else {
152147
resetConnectedEdgeStyle();
153148
}
154-
155-
const selectedRoots = selectRoots(event.cy);
156-
const layout = cy.layout(
157-
getLayoutOptions(selectedRoots, height, width, nodeHeight)
158-
);
159-
160-
layout.run();
149+
cy.layout(getLayoutOptions(nodeHeight)).run();
161150
}
162151
};
163152
let layoutstopDelayTimeout: NodeJS.Timeout;
@@ -180,6 +169,7 @@ export function Cytoscape({
180169
event.cy.fit(undefined, nodeHeight);
181170
}
182171
}, 0);
172+
applyCubicBezierStyles(event.cy.edges());
183173
};
184174
// debounce hover tracking so it doesn't spam telemetry with redundant events
185175
const trackNodeEdgeHover = debounce(
@@ -211,6 +201,9 @@ export function Cytoscape({
211201
console.debug('cytoscape:', event);
212202
}
213203
};
204+
const dragHandler: cytoscape.EventHandler = (event) => {
205+
applyCubicBezierStyles(event.target.connectedEdges());
206+
};
214207

215208
if (cy) {
216209
cy.on('data layoutstop select unselect', debugHandler);
@@ -220,6 +213,7 @@ export function Cytoscape({
220213
cy.on('mouseout', 'edge, node', mouseoutHandler);
221214
cy.on('select', 'node', selectHandler);
222215
cy.on('unselect', 'node', unselectHandler);
216+
cy.on('drag', 'node', dragHandler);
223217

224218
cy.remove(cy.elements());
225219
cy.add(elements);
@@ -239,19 +233,11 @@ export function Cytoscape({
239233
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
240234
cy.removeListener('select', 'node', selectHandler);
241235
cy.removeListener('unselect', 'node', unselectHandler);
236+
cy.removeListener('drag', 'node', dragHandler);
242237
}
243238
clearTimeout(layoutstopDelayTimeout);
244239
};
245-
}, [
246-
cy,
247-
elements,
248-
height,
249-
serviceName,
250-
trackApmEvent,
251-
width,
252-
nodeHeight,
253-
theme,
254-
]);
240+
}, [cy, elements, height, serviceName, trackApmEvent, nodeHeight, theme]);
255241

256242
return (
257243
<CytoscapeContext.Provider value={cy}>

x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,15 @@ export function Popover({ focusedServiceName }: PopoverProps) {
7171
cy.on('select', 'node', selectHandler);
7272
cy.on('unselect', 'node', deselect);
7373
cy.on('data viewport', deselect);
74+
cy.on('drag', 'node', deselect);
7475
}
7576

7677
return () => {
7778
if (cy) {
7879
cy.removeListener('select', 'node', selectHandler);
7980
cy.removeListener('unselect', 'node', deselect);
8081
cy.removeListener('data viewport', undefined, deselect);
82+
cy.removeListener('drag', 'node', deselect);
8183
}
8284
};
8385
}, [cy, deselect]);

x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,11 @@ storiesOf('app/ServiceMap/Cytoscape', module)
4949
},
5050
];
5151
const height = 300;
52-
const width = 1340;
5352
const serviceName = 'opbeans-python';
5453
return (
5554
<Cytoscape
5655
elements={elements}
5756
height={height}
58-
width={width}
5957
serviceName={serviceName}
6058
/>
6159
);
@@ -330,7 +328,7 @@ storiesOf('app/ServiceMap/Cytoscape', module)
330328
},
331329
},
332330
];
333-
return <Cytoscape elements={elements} height={600} width={1340} />;
331+
return <Cytoscape elements={elements} height={600} />;
334332
},
335333
{
336334
info: { propTables: false, source: false },

x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ function setSessionJson(json: string) {
3535
window.sessionStorage.setItem(SESSION_STORAGE_KEY, json);
3636
}
3737

38+
const getCytoscapeHeight = () => window.innerHeight - 300;
39+
3840
storiesOf(STORYBOOK_PATH, module)
3941
.addDecorator((storyFn) => <EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
4042
.add(
@@ -43,16 +45,17 @@ storiesOf(STORYBOOK_PATH, module)
4345
const [size, setSize] = useState<number>(10);
4446
const [json, setJson] = useState<string>('');
4547
const [elements, setElements] = useState<any[]>(
46-
generateServiceMapElements(size)
48+
generateServiceMapElements({ size, hasAnomalies: true })
4749
);
48-
4950
return (
5051
<div>
5152
<EuiFlexGroup>
5253
<EuiFlexItem>
5354
<EuiButton
5455
onClick={() => {
55-
setElements(generateServiceMapElements(size));
56+
setElements(
57+
generateServiceMapElements({ size, hasAnomalies: true })
58+
);
5659
setJson('');
5760
}}
5861
>
@@ -79,7 +82,7 @@ storiesOf(STORYBOOK_PATH, module)
7982
</EuiFlexItem>
8083
</EuiFlexGroup>
8184

82-
<Cytoscape elements={elements} height={600} width={1340} />
85+
<Cytoscape elements={elements} height={getCytoscapeHeight()} />
8386

8487
{json && (
8588
<EuiCodeEditor
@@ -121,7 +124,7 @@ storiesOf(STORYBOOK_PATH, module)
121124

122125
return (
123126
<div>
124-
<Cytoscape elements={elements} height={600} width={1340} />
127+
<Cytoscape elements={elements} height={getCytoscapeHeight()} />
125128
<EuiForm isInvalid={error !== undefined} error={error}>
126129
<EuiFlexGroup>
127130
<EuiFlexItem>
@@ -204,8 +207,7 @@ storiesOf(STORYBOOK_PATH, module)
204207
<div>
205208
<Cytoscape
206209
elements={exampleResponseTodo.elements}
207-
height={600}
208-
width={1340}
210+
height={getCytoscapeHeight()}
209211
/>
210212
</div>
211213
);
@@ -224,8 +226,7 @@ storiesOf(STORYBOOK_PATH, module)
224226
<div>
225227
<Cytoscape
226228
elements={exampleResponseOpbeansBeats.elements}
227-
height={600}
228-
width={1340}
229+
height={getCytoscapeHeight()}
229230
/>
230231
</div>
231232
);
@@ -244,8 +245,7 @@ storiesOf(STORYBOOK_PATH, module)
244245
<div>
245246
<Cytoscape
246247
elements={exampleResponseHipsterStore.elements}
247-
height={600}
248-
width={1340}
248+
height={getCytoscapeHeight()}
249249
/>
250250
</div>
251251
);
@@ -264,8 +264,7 @@ storiesOf(STORYBOOK_PATH, module)
264264
<div>
265265
<Cytoscape
266266
elements={exampleResponseOneDomainManyIPs.elements}
267-
height={600}
268-
width={1340}
267+
height={getCytoscapeHeight()}
269268
/>
270269
</div>
271270
);

x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import { getSeverity } from '../Popover/getSeverity';
8-
9-
export function generateServiceMapElements(size: number): any[] {
7+
export function generateServiceMapElements({
8+
size,
9+
hasAnomalies,
10+
}: {
11+
size: number;
12+
hasAnomalies: boolean;
13+
}): any[] {
1014
const services = range(size).map((i) => {
1115
const name = getName();
1216
const anomalyScore = randn(101);
@@ -15,11 +19,14 @@ export function generateServiceMapElements(size: number): any[] {
1519
'service.environment': 'production',
1620
'service.name': name,
1721
'agent.name': getAgentName(),
18-
anomaly_score: anomalyScore,
19-
anomaly_severity: getSeverity(anomalyScore),
20-
actual_value: Math.random() * 2000000,
21-
typical_value: Math.random() * 1000000,
22-
ml_job_id: `${name}-request-high_mean_response_time`,
22+
serviceAnomalyStats: hasAnomalies
23+
? {
24+
transactionType: 'request',
25+
anomalyScore,
26+
actualValue: Math.random() * 2000000,
27+
jobId: `${name}-request-high_mean_response_time`,
28+
}
29+
: undefined,
2330
};
2431
});
2532

@@ -146,7 +153,7 @@ const NAMES = [
146153
'leech',
147154
'loki',
148155
'longshot',
149-
'lumpkin,',
156+
'lumpkin',
150157
'madame-web',
151158
'magician',
152159
'magneto',

0 commit comments

Comments
 (0)