Skip to content

Commit 0002d09

Browse files
[8.19] [APM] Storybook support for the new service map API response (#213980) (#219994)
# Backport This will backport the following commits from `main` to `8.19`: - [[APM] Storybook support for the new service map API response (#213980)](#213980) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Carlos Crespo","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-03-12T11:38:11Z","message":"[APM] Storybook support for the new service map API response (#213980)\n\ncloses [213126](https://github.com/elastic/kibana/issues/213126)\n\n## Summary\n\nAdd support for the new API response to the Service Map storybook\n\n\n\n![storybook](https://github.com/user-attachments/assets/3e5fbf96-ccee-43a7-b64f-b5a81fd52998)","sha":"4502a930d5d6e34f5d45c9e7a633b763ef1d82bd","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:skip","Team:obs-ux-infra_services","v9.1.0"],"title":"[APM] Storybook support for the new service map API response","number":213980,"url":"https://github.com/elastic/kibana/pull/213980","mergeCommit":{"message":"[APM] Storybook support for the new service map API response (#213980)\n\ncloses [213126](https://github.com/elastic/kibana/issues/213126)\n\n## Summary\n\nAdd support for the new API response to the Service Map storybook\n\n\n\n![storybook](https://github.com/user-attachments/assets/3e5fbf96-ccee-43a7-b64f-b5a81fd52998)","sha":"4502a930d5d6e34f5d45c9e7a633b763ef1d82bd"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/213980","number":213980,"mergeCommit":{"message":"[APM] Storybook support for the new service map API response (#213980)\n\ncloses [213126](https://github.com/elastic/kibana/issues/213126)\n\n## Summary\n\nAdd support for the new API response to the Service Map storybook\n\n\n\n![storybook](https://github.com/user-attachments/assets/3e5fbf96-ccee-43a7-b64f-b5a81fd52998)","sha":"4502a930d5d6e34f5d45c9e7a633b763ef1d82bd"}}]}] BACKPORT--> --------- Co-authored-by: Elastic Machine <[email protected]>
1 parent b8d6201 commit 0002d09

File tree

5 files changed

+292
-122
lines changed

5 files changed

+292
-122
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 type { ConnectionNode, ExitSpanDestination, ServiceMapSpan } from './types';
9+
import { getConnections, getExternalConnectionNode, getServiceConnectionNode } from './utils';
10+
11+
export const getPaths = ({ spans }: { spans: ServiceMapSpan[] }) => {
12+
const connections: ConnectionNode[][] = [];
13+
const exitSpanDestinations: ExitSpanDestination[] = [];
14+
15+
for (const currentNode of spans) {
16+
const exitSpanNode = getExternalConnectionNode(currentNode);
17+
const serviceNode = getServiceConnectionNode(currentNode);
18+
19+
if (currentNode.destinationService) {
20+
// maps an exit span to its destination service
21+
exitSpanDestinations.push({
22+
from: exitSpanNode,
23+
to: getServiceConnectionNode(currentNode.destinationService),
24+
});
25+
}
26+
27+
// builds a connection between a service and an exit span
28+
connections.push([serviceNode, exitSpanNode]);
29+
}
30+
31+
return {
32+
connections: getConnections(connections),
33+
exitSpanDestinations,
34+
};
35+
};

x-pack/solutions/observability/plugins/apm/common/service_map/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424

2525
export * from './utils';
2626
export { getServiceMapNodes } from './get_service_map_nodes';
27+
export { getPaths } from './get_paths';
2728

2829
export type {
2930
Connection,

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx

Lines changed: 93 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@
77

88
import {
99
EuiButton,
10+
EuiCallOut,
1011
EuiFieldNumber,
1112
EuiFilePicker,
1213
EuiFlexGroup,
1314
EuiFlexItem,
1415
EuiForm,
15-
EuiSpacer,
1616
EuiToolTip,
1717
} from '@elastic/eui';
1818
import type { Meta, StoryFn } from '@storybook/react';
19-
import React, { useEffect, useState } from 'react';
19+
import React, { useCallback, useEffect, useState } from 'react';
2020
import { CodeEditor } from '@kbn/code-editor';
21+
import { useArgs } from '@storybook/preview-api';
22+
import {
23+
getPaths,
24+
getServiceMapNodes,
25+
type ServiceMapResponse,
26+
} from '../../../../../common/service_map';
2127
import { Cytoscape } from '../cytoscape';
2228
import { Centerer } from './centerer';
2329
import exampleResponseHipsterStore from './example_response_hipster_store.json';
@@ -26,36 +32,16 @@ import exampleResponseTodo from './example_response_todo.json';
2632
import { generateServiceMapElements } from './generate_service_map_elements';
2733
import { MockApmPluginStorybook } from '../../../../context/apm_plugin/mock_apm_plugin_storybook';
2834

29-
const STORYBOOK_PATH = 'app/ServiceMap/Example data';
30-
31-
const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`;
32-
function getSessionJson() {
33-
return window.sessionStorage.getItem(SESSION_STORAGE_KEY);
34-
}
35-
function setSessionJson(json: string) {
36-
window.sessionStorage.setItem(SESSION_STORAGE_KEY, json);
37-
}
38-
3935
function getHeight() {
40-
return window.innerHeight - 300;
36+
return window.innerHeight - 200;
4137
}
4238

4339
const stories: Meta<{}> = {
4440
title: 'app/ServiceMap/Example data',
4541
component: Cytoscape,
46-
decorators: [
47-
(StoryComponent, { globals }) => {
48-
return (
49-
<MockApmPluginStorybook>
50-
<StoryComponent />
51-
</MockApmPluginStorybook>
52-
);
53-
},
54-
],
42+
decorators: [(wrappedStory) => <MockApmPluginStorybook>{wrappedStory()}</MockApmPluginStorybook>],
5543
};
5644

57-
export default stories;
58-
5945
export const GenerateMap: StoryFn<{}> = () => {
6046
const [size, setSize] = useState<number>(10);
6147
const [json, setJson] = useState<string>('');
@@ -114,87 +100,106 @@ export const GenerateMap: StoryFn<{}> = () => {
114100
);
115101
};
116102

117-
export const MapFromJSON: StoryFn<{}> = () => {
118-
const [json, setJson] = useState<string>(
119-
getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2)
120-
);
121-
const [error, setError] = useState<string | undefined>();
103+
interface MapFromJSONArgs {
104+
json: unknown;
105+
}
106+
const assertJSON: (json?: any) => asserts json is ServiceMapResponse = (json) => {
107+
if (!!json && !('elements' in json || 'spans' in json)) {
108+
throw new Error('invalid json');
109+
}
110+
};
111+
112+
const MapFromJSONTemplate: StoryFn<MapFromJSONArgs> = (args) => {
113+
const [{ json }, updateArgs] = useArgs();
122114

115+
const [error, setError] = useState<string | undefined>();
123116
const [elements, setElements] = useState<any[]>([]);
124117

125118
const [uniqueKeyCounter, setUniqueKeyCounter] = useState<number>(0);
126-
const updateRenderedElements = () => {
119+
const updateRenderedElements = useCallback(() => {
127120
try {
128-
setElements(JSON.parse(json).elements);
121+
assertJSON(json);
122+
if ('elements' in json) {
123+
setElements(json.elements ?? []);
124+
} else {
125+
const paths = getPaths({ spans: json.spans ?? [] });
126+
const nodes = getServiceMapNodes({
127+
anomalies: json.anomalies ?? {
128+
mlJobIds: [],
129+
serviceAnomalies: [],
130+
},
131+
connections: paths.connections,
132+
servicesData: json.servicesData ?? [],
133+
exitSpanDestinations: paths.exitSpanDestinations,
134+
});
135+
136+
setElements(nodes.elements);
137+
}
129138
setUniqueKeyCounter((key) => key + 1);
130139
setError(undefined);
131140
} catch (e) {
132141
setError(e.message);
133142
}
134-
};
143+
}, [json]);
135144

136145
useEffect(() => {
137146
updateRenderedElements();
138-
// eslint-disable-next-line react-hooks/exhaustive-deps
139-
}, []);
147+
}, [updateRenderedElements]);
140148

141149
return (
142-
<div>
143-
<Cytoscape key={uniqueKeyCounter} elements={elements} height={getHeight()}>
144-
<Centerer />
145-
</Cytoscape>
146-
<EuiForm isInvalid={error !== undefined} error={error}>
147-
<EuiFlexGroup>
148-
<EuiFlexItem>
149-
<CodeEditor // TODO Unable to find context that provides theme. Need CODEOWNER Input
150-
languageId="json"
151-
value={json}
152-
options={{ fontFamily: 'monospace' }}
153-
onChange={(value) => {
154-
setJson(value);
155-
setSessionJson(value);
156-
}}
157-
/>
158-
</EuiFlexItem>
159-
<EuiFlexItem>
160-
<EuiFlexGroup direction="column">
161-
<EuiFilePicker
162-
display={'large'}
163-
fullWidth={true}
164-
style={{ height: '100%' }}
165-
initialPromptText="Upload a JSON file"
166-
onChange={(event) => {
167-
const item = event?.item(0);
168-
169-
if (item) {
170-
const f = new FileReader();
171-
f.onload = (onloadEvent) => {
172-
const result = onloadEvent?.target?.result;
173-
if (typeof result === 'string') {
174-
setJson(result);
175-
}
176-
};
177-
f.readAsText(item);
150+
<EuiFlexGroup
151+
direction="column"
152+
justifyContent="spaceBetween"
153+
style={{ minHeight: '100vh' }}
154+
gutterSize="xs"
155+
>
156+
<EuiFlexItem grow={false}>
157+
<EuiCallOut
158+
size="s"
159+
title="Upload a JSON file or paste a JSON object in the Storybook Controls panel."
160+
iconType="pin"
161+
/>
162+
</EuiFlexItem>
163+
<EuiFlexItem grow>
164+
<Cytoscape key={uniqueKeyCounter} elements={elements} height={getHeight()}>
165+
<Centerer />
166+
</Cytoscape>
167+
</EuiFlexItem>
168+
<EuiFlexItem grow={false}>
169+
<EuiForm isInvalid={error !== undefined} error={error}>
170+
<EuiFilePicker
171+
display="large"
172+
fullWidth
173+
initialPromptText="Upload a JSON file"
174+
onChange={(event) => {
175+
const item = event?.item(0);
176+
177+
if (item) {
178+
const f = new FileReader();
179+
f.onload = (onloadEvent) => {
180+
const result = onloadEvent?.target?.result;
181+
if (typeof result === 'string') {
182+
updateArgs({ json: JSON.parse(result) });
178183
}
179-
}}
180-
/>
181-
<EuiSpacer />
182-
<EuiButton
183-
data-test-subj="apmMapFromJSONRenderJsonButton"
184-
onClick={() => {
185-
updateRenderedElements();
186-
}}
187-
>
188-
Render JSON
189-
</EuiButton>
190-
</EuiFlexGroup>
191-
</EuiFlexItem>
192-
</EuiFlexGroup>
193-
</EuiForm>
194-
</div>
184+
};
185+
f.readAsText(item);
186+
}
187+
}}
188+
/>
189+
</EuiForm>
190+
</EuiFlexItem>
191+
</EuiFlexGroup>
195192
);
196193
};
197194

195+
export const MapFromJSON = MapFromJSONTemplate.bind({});
196+
MapFromJSON.argTypes = {
197+
json: {
198+
defaultValue: exampleResponseTodo,
199+
control: 'object',
200+
},
201+
};
202+
198203
export const TodoApp: StoryFn<{}> = () => {
199204
return (
200205
<div>
@@ -224,3 +229,5 @@ export const HipsterStore: StoryFn<{}> = () => {
224229
</div>
225230
);
226231
};
232+
233+
export default stories;

0 commit comments

Comments
 (0)