Skip to content

Commit 5ac3351

Browse files
authored
Add info control with legend popover to Service map (elastic#270184)
Closes elastic#268609 ## Summary - Add legend to service map - Fix edges lines during search/highlight ### Before and After #### Before https://github.com/user-attachments/assets/131892ee-1e01-4143-8fda-c4c1ec5c1c54 #### After https://github.com/user-attachments/assets/30242922-5cfc-461d-b97a-9882e8543e7b <img width="333" height="496" alt="NewLegend" src="https://github.com/user-attachments/assets/09b19a90-b46f-40d0-8dc9-618f6e16f02b" /> ### Follow-up required elastic#270399
1 parent a861300 commit 5ac3351

11 files changed

Lines changed: 588 additions & 11 deletions

File tree

src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
5454
apm: {
5555
kibanaSettings: `${ELASTIC_DOCS}solutions/observability/apm/applications-ui-settings`,
5656
supportedServiceMaps: `${ELASTIC_DOCS}solutions/observability/apm/service-map`,
57+
supportedServiceMapsLegend: `${ELASTIC_DOCS}solutions/observability/apm/service-map#service-maps-legend`,
5758
customLinks: `${ELASTIC_DOCS}solutions/observability/apm/create-custom-links`,
5859
droppedTransactionSpans: `${ELASTIC_DOCS}solutions/observability/apm/spans#apm-data-model-dropped-spans`,
5960
upgrading: `${ELASTIC_DOCS}solutions/observability/apm/upgrade`,

src/platform/packages/shared/kbn-doc-links/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface DocLinks {
3838
readonly apm: {
3939
readonly kibanaSettings: string;
4040
readonly supportedServiceMaps: string;
41+
readonly supportedServiceMapsLegend: string;
4142
readonly customLinks: string;
4243
readonly droppedTransactionSpans: string;
4344
readonly upgrading: string;

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const MOCK_EUI_THEME = {
7979
mediumShade: '#98A2B3',
8080
primaryText: '#0077CC',
8181
textPrimary: '#1a1c21',
82+
textSubdued: '#69707D',
8283
emptyShade: '#fff',
8384
backgroundBasePlain: '#fff',
8485
backgroundBaseHighlighted: '#F6F9FC',
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { useCallback } from 'react';
9+
import { Position, useReactFlow } from '@xyflow/react';
10+
import { useEuiTheme } from '@elastic/eui';
11+
import { SERVICE_NODE_CIRCLE_SIZE } from '../../../../common/service_map/constants';
12+
13+
/**
14+
* Hook that returns a function to compute adjusted edge endpoints.
15+
* Encapsulates access to React Flow internal nodes and EUI theme values
16+
* so the edge component doesn't need to manage those concerns.
17+
*/
18+
export function useAdjustedEndpoint() {
19+
const { getInternalNode } = useReactFlow();
20+
const { euiTheme } = useEuiTheme();
21+
const wrapperInset = parseInt(euiTheme.size.m, 10);
22+
const smallestSize = parseInt(euiTheme.size.xxs, 10);
23+
const smallerSize = parseInt(euiTheme.size.xs, 10);
24+
25+
return useCallback(
26+
(
27+
nodeId: string,
28+
x: number,
29+
y: number,
30+
refX: number,
31+
refY: number,
32+
position: Position
33+
): { x: number; y: number } => {
34+
const node = getInternalNode(nodeId);
35+
36+
let offset = wrapperInset;
37+
38+
if (node?.measured) {
39+
const { width, height } = node.measured;
40+
41+
if (position === Position.Left || position === Position.Right) {
42+
offset = Math.max(
43+
((width ?? 0) - SERVICE_NODE_CIRCLE_SIZE) / 2 + smallerSize,
44+
wrapperInset
45+
);
46+
} else if (position === Position.Bottom) {
47+
const nodeHeight = height ?? 0;
48+
offset = Math.max(
49+
nodeHeight - SERVICE_NODE_CIRCLE_SIZE / 2 - (wrapperInset - smallestSize) * 3,
50+
wrapperInset
51+
);
52+
}
53+
}
54+
55+
if (position === Position.Left || position === Position.Right) {
56+
return { x: x + (refX > x ? offset : -offset), y };
57+
}
58+
return { x, y: y + (refY > y ? offset : -offset) };
59+
},
60+
[getInternalNode, wrapperInset, smallestSize, smallerSize]
61+
);
62+
}

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/graph.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
ServiceMapOptionsPanelToggle,
6262
type ServiceMapOrientation,
6363
} from './service_map_options_panel';
64+
import { ServiceMapLegend } from './service_map_legend';
6465
import type { Environment } from '../../../../common/environment_rt';
6566
import {
6667
isServiceNode,
@@ -210,12 +211,24 @@ function GraphInner({
210211

211212
useEffect(() => {
212213
setNodes(nodesWithContextHighlight);
213-
setEdges(
214-
applyEdgeHighlighting(edgesAfterFilters, {
215-
selectedNodeId: selectedEdgeForPopoverRef.current ? null : selectedNodeIdRef.current,
216-
selectedEdgeId: selectedEdgeForPopoverRef.current,
217-
})
218-
);
214+
215+
const highlightedEdges = applyEdgeHighlighting(edgesAfterFilters, {
216+
selectedNodeId: selectedEdgeForPopoverRef.current ? null : selectedNodeIdRef.current,
217+
selectedEdgeId: selectedEdgeForPopoverRef.current,
218+
});
219+
220+
const edgesWithContextHighlight = highlightedServiceName
221+
? highlightedEdges.map((edge) => ({
222+
...edge,
223+
data: {
224+
...edge.data,
225+
sourceContextHighlight: edge.source === highlightedServiceName,
226+
targetContextHighlight: edge.target === highlightedServiceName,
227+
},
228+
}))
229+
: highlightedEdges;
230+
231+
setEdges(edgesWithContextHighlight as ServiceMapEdgeType[]);
219232

220233
if (nodesAfterFilters.length > 0) {
221234
const timer = setTimeout(() => fitView(getFitViewOptions()), FIT_VIEW_DEFER_MS);
@@ -230,6 +243,7 @@ function GraphInner({
230243
applyEdgeHighlighting,
231244
getFitViewOptions,
232245
nodesAfterFilters.length,
246+
highlightedServiceName,
233247
]);
234248

235249
const handleNodeClick: NodeMouseHandler<ServiceMapNode> = useCallback(
@@ -637,6 +651,15 @@ function GraphInner({
637651
)}
638652
</EuiFlexGroup>
639653
</EuiPanel>
654+
<EuiPanel
655+
hasBorder
656+
hasShadow={false}
657+
paddingSize="none"
658+
borderRadius="m"
659+
grow={false}
660+
>
661+
<ServiceMapLegend controlIconCss={mapToolbarControlIconCss} />
662+
</EuiPanel>
640663
</div>
641664
{!isEmbedded && panelExpanded && (
642665
<ServiceMapOptionsPanel

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/graph_controls.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ jest.mock('./service_map_minimap', () => ({
8383
ServiceMapMinimap: () => <div data-testid="react-flow-minimap" />,
8484
}));
8585

86+
jest.mock('../../../context/apm_plugin/use_apm_plugin_context', () => ({
87+
useApmPluginContext: () => ({
88+
core: {
89+
docLinks: {
90+
links: {
91+
apm: {
92+
supportedServiceMaps: 'https://example.com/docs',
93+
supportedServiceMapsLegend: 'https://example.com/docs#service-maps-legend',
94+
},
95+
},
96+
},
97+
},
98+
}),
99+
}));
100+
86101
const createMockNode = (id: string, label: string): ServiceMapNode => ({
87102
id,
88103
position: { x: 100, y: 100 },

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/graph_minimap.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ jest.mock('@elastic/eui', () => {
2121
};
2222
});
2323

24+
jest.mock('../../../context/apm_plugin/use_apm_plugin_context', () => ({
25+
useApmPluginContext: () => ({
26+
core: {
27+
docLinks: {
28+
links: {
29+
apm: {
30+
supportedServiceMaps:
31+
'https://www.elastic.co/guide/en/kibana/current/service-maps.html',
32+
supportedServiceMapsLegend:
33+
'https://www.elastic.co/guide/en/kibana/current/service-maps.html#service-maps-legend',
34+
},
35+
},
36+
},
37+
},
38+
}),
39+
}));
40+
2441
jest.mock('./use_keyboard_navigation', () => ({
2542
useKeyboardNavigation: jest.fn(() => ({
2643
screenReaderAnnouncement: '',

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/graph_screen_reader.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ jest.mock('@elastic/eui', () => {
1919
useEuiTheme: () => ({ euiTheme: MOCK_EUI_THEME_FOR_USE_THEME }),
2020
};
2121
});
22+
23+
jest.mock('../../../context/apm_plugin/use_apm_plugin_context', () => ({
24+
useApmPluginContext: () => ({
25+
core: {
26+
docLinks: {
27+
links: {
28+
apm: {
29+
supportedServiceMaps: 'https://example.com/docs',
30+
supportedServiceMapsLegend: 'https://example.com/docs#service-maps-legend',
31+
},
32+
},
33+
},
34+
},
35+
}),
36+
}));
37+
2238
let mockScreenReaderAnnouncementValue = '';
2339
const mockSetScreenReaderAnnouncement = jest.fn();
2440

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/service_map_edge.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
*/
77

88
import React, { memo } from 'react';
9-
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react';
9+
import { BaseEdge, getBezierPath, Position, type EdgeProps } from '@xyflow/react';
10+
import { useServiceMapSearchContext } from '../../shared/service_map/service_map_search_context';
11+
import { useAdjustedEndpoint } from './get_highlight_offset';
1012

1113
export const ServiceMapEdge = memo(
1214
({
1315
id,
16+
source,
17+
target,
1418
sourceX,
1519
sourceY,
1620
targetX,
@@ -20,13 +24,28 @@ export const ServiceMapEdge = memo(
2024
style,
2125
markerEnd,
2226
markerStart,
27+
data,
2328
}: EdgeProps) => {
29+
const { activeMatchNodeId } = useServiceMapSearchContext();
30+
const adjustEndpoint = useAdjustedEndpoint();
31+
32+
const sourceHighlighted = Boolean(data?.sourceContextHighlight) || activeMatchNodeId === source;
33+
const targetHighlighted = Boolean(data?.targetContextHighlight) || activeMatchNodeId === target;
34+
35+
const { x: sX, y: sY } = sourceHighlighted
36+
? adjustEndpoint(source, sourceX, sourceY, targetX, targetY, sourcePosition ?? Position.Right)
37+
: { x: sourceX, y: sourceY };
38+
39+
const { x: tX, y: tY } = targetHighlighted
40+
? adjustEndpoint(target, targetX, targetY, sourceX, sourceY, targetPosition ?? Position.Left)
41+
: { x: targetX, y: targetY };
42+
2443
const [edgePath] = getBezierPath({
25-
sourceX,
26-
sourceY,
44+
sourceX: sX,
45+
sourceY: sY,
2746
sourcePosition,
28-
targetX,
29-
targetY,
47+
targetX: tX,
48+
targetY: tY,
3049
targetPosition,
3150
});
3251

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { act, fireEvent, render, screen } from '@testing-library/react';
10+
import { css } from '@emotion/react';
11+
import { ServiceMapLegend } from './service_map_legend';
12+
import { MOCK_EUI_THEME_FOR_USE_THEME } from './constants';
13+
14+
const MOCK_DOCS_LINK = 'https://www.elastic.co/docs/apm/service-maps#service-maps-legend';
15+
16+
jest.mock('@elastic/eui', () => {
17+
const original = jest.requireActual('@elastic/eui');
18+
return {
19+
...original,
20+
useEuiTheme: () => ({ euiTheme: MOCK_EUI_THEME_FOR_USE_THEME }),
21+
};
22+
});
23+
24+
jest.mock('../../../context/apm_plugin/use_apm_plugin_context', () => ({
25+
useApmPluginContext: () => ({
26+
core: {
27+
docLinks: {
28+
links: {
29+
apm: {
30+
supportedServiceMaps: MOCK_DOCS_LINK,
31+
supportedServiceMapsLegend: MOCK_DOCS_LINK,
32+
},
33+
},
34+
},
35+
},
36+
}),
37+
}));
38+
39+
const controlIconCss = css`
40+
min-inline-size: 32px;
41+
min-block-size: 32px;
42+
`;
43+
44+
describe('ServiceMapLegend', () => {
45+
it('renders the legend button without errors', () => {
46+
render(<ServiceMapLegend controlIconCss={controlIconCss} />);
47+
48+
const button = screen.getByTestId('serviceMapLegendButton');
49+
expect(button).toBeInTheDocument();
50+
expect(button).toHaveAttribute('aria-label', 'Legend');
51+
});
52+
53+
it('opens the popover when the button is clicked', async () => {
54+
render(<ServiceMapLegend controlIconCss={controlIconCss} />);
55+
56+
expect(screen.queryByText('Node shapes')).not.toBeInTheDocument();
57+
58+
await act(async () => {
59+
fireEvent.click(screen.getByTestId('serviceMapLegendButton'));
60+
});
61+
62+
expect(screen.getByText('Node shapes')).toBeInTheDocument();
63+
expect(screen.getByText('Connections')).toBeInTheDocument();
64+
expect(screen.getByText('Anomaly score')).toBeInTheDocument();
65+
});
66+
67+
it('renders the docs link pointing to the service maps documentation', async () => {
68+
render(<ServiceMapLegend controlIconCss={controlIconCss} />);
69+
70+
await act(async () => {
71+
fireEvent.click(screen.getByTestId('serviceMapLegendButton'));
72+
});
73+
74+
const docsLink = screen.getByTestId('serviceMapLegendDocsLink');
75+
expect(docsLink).toBeInTheDocument();
76+
expect(docsLink).toHaveAttribute('href', MOCK_DOCS_LINK);
77+
});
78+
});

0 commit comments

Comments
 (0)