) => void;
- selectedNodeData: cytoscape.NodeDataDefinition;
- selectedNodeServiceName: string;
-}
-
-// IE 11 does not handle flex properties as expected. With browser detection,
-// we can use regular div elements to render contents that are almost identical.
-//
-// This method of detecting IE is from a Stack Overflow answer:
-// https://stackoverflow.com/a/21825207
-//
-// @ts-expect-error `documentMode` is not recognized as a valid property of `document`.
-const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
-
-function FlexColumnGroup(props: {
- children: React.ReactNode;
- style: React.CSSProperties;
- direction: 'column';
- gutterSize: 's';
-}) {
- if (isIE11) {
- const { direction, gutterSize, ...rest } = props;
- return ;
- }
- return ;
-}
-function FlexColumnItem(props: { children: React.ReactNode }) {
- return isIE11 ? : ;
-}
-
-export function Contents({
- selectedNodeData,
- isService,
- label,
- onFocusClick,
- selectedNodeServiceName,
-}: ContentsProps) {
- return (
-
-
-
- {label}
-
-
-
-
- {isService ? (
-
- ) : (
-
- )}
-
- {isService && (
-
- )}
-
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx
deleted file mode 100644
index 9577a02d68cf2..0000000000000
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx
+++ /dev/null
@@ -1,116 +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
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import {
- EuiDescriptionList,
- EuiDescriptionListTitle,
- EuiDescriptionListDescription,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import cytoscape from 'cytoscape';
-import React, { Fragment } from 'react';
-import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
-import {
- SPAN_SUBTYPE,
- SPAN_TYPE,
-} from '../../../../../common/elasticsearch_fieldnames';
-import { ExternalConnectionNode } from '../../../../../common/service_map';
-
-const ItemRow = euiStyled.div`
- line-height: 2;
-`;
-
-const SubduedDescriptionListTitle = euiStyled(EuiDescriptionListTitle)`
- &&& {
- color: ${({ theme }) => theme.eui.euiTextSubduedColor};
- }
-`;
-
-const ExternalResourcesList = euiStyled.section`
- max-height: 360px;
- overflow: auto;
-`;
-
-interface InfoProps extends cytoscape.NodeDataDefinition {
- type?: string;
- subtype?: string;
- className?: string;
-}
-
-export function Info(data: InfoProps) {
- // For nodes with span.type "db", convert it to "database".
- // Otherwise leave it as-is.
- const type = data[SPAN_TYPE] === 'db' ? 'database' : data[SPAN_TYPE];
-
- // Externals should not have a subtype so make it undefined if the type is external.
- const subtype = data[SPAN_TYPE] !== 'external' && data[SPAN_SUBTYPE];
-
- const listItems = [
- {
- title: i18n.translate('xpack.apm.serviceMap.typePopoverStat', {
- defaultMessage: 'Type',
- }),
- description: type,
- },
- {
- title: i18n.translate('xpack.apm.serviceMap.subtypePopoverStat', {
- defaultMessage: 'Subtype',
- }),
- description: subtype,
- },
- ];
-
- if (data.groupedConnections) {
- return (
-
-
- {data.groupedConnections.map((resource: ExternalConnectionNode) => {
- const title =
- resource.label || resource['span.destination.service.resource'];
- const desc = `${resource['span.type']} (${resource['span.subtype']})`;
- return (
-
-
- {title}
-
-
- {desc}
-
-
- );
- })}
-
-
- );
- }
-
- return (
- <>
- {listItems.map(
- ({ title, description }) =>
- description && (
-
-
-
- {title}
-
-
- {description}
-
-
-
- )
- )}
- >
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx
index 324a38ea5db39..faf807d4d4fc3 100644
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx
@@ -5,91 +5,158 @@
* 2.0.
*/
+import { Meta, Story } from '@storybook/react';
import cytoscape from 'cytoscape';
import { CoreStart } from 'kibana/public';
-import React, { ComponentType } from 'react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { Popover } from '.';
+import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public';
import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context';
import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider';
import { createCallApmApi } from '../../../../services/rest/createCallApmApi';
import { CytoscapeContext } from '../Cytoscape';
-import { Popover } from '.';
import exampleGroupedConnectionsData from '../__stories__/example_grouped_connections.json';
-export default {
+interface Args {
+ nodeData: cytoscape.NodeDataDefinition;
+}
+
+const stories: Meta = {
title: 'app/ServiceMap/Popover',
component: Popover,
decorators: [
- (Story: ComponentType) => {
+ (StoryComponent) => {
const coreMock = ({
http: {
- get: async () => ({
- avgCpuUsage: 0.32809666568309237,
- avgErrorRate: 0.556068173242986,
- avgMemoryUsage: 0.5504868173242986,
- transactionStats: {
- avgRequestsPerMinute: 164.47222031860858,
- avgTransactionDuration: 61634.38905590272,
- },
- }),
+ get: () => {
+ return {
+ avgCpuUsage: 0.32809666568309237,
+ avgErrorRate: 0.556068173242986,
+ avgMemoryUsage: 0.5504868173242986,
+ transactionStats: {
+ avgRequestsPerMinute: 164.47222031860858,
+ avgTransactionDuration: 61634.38905590272,
+ },
+ };
+ },
},
+ notifications: { toasts: { add: () => {} } },
+ uiSettings: { get: () => ({}) },
} as unknown) as CoreStart;
+ const KibanaReactContext = createKibanaReactContext(coreMock);
+
createCallApmApi(coreMock);
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+ (StoryComponent, { args }) => {
+ const node = {
+ data: args?.nodeData!,
+ };
+
+ const cy = cytoscape({
+ elements: [
+ { data: { id: 'upstreamService' } },
+ {
+ data: {
+ id: 'edge',
+ source: 'upstreamService',
+ target: node.data.id,
+ },
+ },
+ node,
+ ],
+ });
+
+ setTimeout(() => {
+ cy.$id(node.data.id!).select();
+ }, 0);
+
+ return (
+
+
+
);
},
],
};
+export default stories;
-export function Example() {
+export const Backend: Story = () => {
return ;
-}
-Example.decorators = [
- (Story: ComponentType) => {
- const node = {
- data: { id: 'example service', 'service.name': 'example service' },
- };
-
- const cy = cytoscape({ elements: [node] });
-
- setTimeout(() => {
- cy.$id('example service').select();
- }, 0);
+};
+Backend.args = {
+ nodeData: {
+ 'span.subtype': 'postgresql',
+ 'span.destination.service.resource': 'postgresql',
+ 'span.type': 'db',
+ id: '>postgresql',
+ label: 'postgresql',
+ },
+};
- return (
-
-
-
- );
+export const BackendWithLongTitle: Story = () => {
+ return ;
+};
+BackendWithLongTitle.args = {
+ nodeData: {
+ 'span.subtype': 'http',
+ 'span.destination.service.resource':
+ '8b37cb7ca2ae49ada54db165f32d3a19.us-central1.gcp.foundit.no:9243',
+ 'span.type': 'external',
+ id: '>8b37cb7ca2ae49ada54db165f32d3a19.us-central1.gcp.foundit.no:9243',
+ label: '8b37cb7ca2ae49ada54db165f32d3a19.us-central1.gcp.foundit.no:9243',
},
-];
+};
-export function Externals() {
+export const ExternalsList: Story = () => {
return ;
-}
-Externals.decorators = [
- (Story: ComponentType) => {
- const node = {
- data: exampleGroupedConnectionsData,
- };
- const cy = cytoscape({ elements: [node] });
+};
+ExternalsList.args = {
+ nodeData: exampleGroupedConnectionsData,
+};
- setTimeout(() => {
- cy.$id(exampleGroupedConnectionsData.id).select();
- }, 0);
+export const Resource: Story = () => {
+ return ;
+};
+Resource.args = {
+ nodeData: {
+ id: '>cdn.loom.com:443',
+ label: 'cdn.loom.com:443',
+ 'span.destination.service.resource': 'cdn.loom.com:443',
+ 'span.subtype': 'css',
+ 'span.type': 'resource',
+ },
+};
- return (
-
-
-
- );
+export const Service: Story = () => {
+ return ;
+};
+Service.args = {
+ nodeData: {
+ id: 'example service',
+ 'service.name': 'example service',
+ serviceAnomalyStats: {
+ serviceName: 'opbeans-java',
+ jobId: 'apm-production-802c-high_mean_transaction_duration',
+ transactionType: 'request',
+ actualValue: 16258.496000000017,
+ anomalyScore: 0,
+ healthStatus: 'healthy',
+ },
},
-];
+};
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx
deleted file mode 100644
index 3155a65b06aca..0000000000000
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx
+++ /dev/null
@@ -1,115 +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
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import {
- EuiLoadingSpinner,
- EuiFlexGroup,
- EuiHorizontalRule,
- EuiText,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { isNumber } from 'lodash';
-import { ServiceNodeStats } from '../../../../../common/service_map';
-import { ServiceStatsList } from './ServiceStatsList';
-import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher';
-import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
-import { AnomalyDetection } from './anomaly_detection';
-import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection';
-
-interface ServiceStatsFetcherProps {
- environment?: string;
- serviceName: string;
- serviceAnomalyStats: ServiceAnomalyStats | undefined;
-}
-
-export function ServiceStatsFetcher({
- serviceName,
- serviceAnomalyStats,
-}: ServiceStatsFetcherProps) {
- const {
- urlParams: { environment, start, end },
- } = useUrlParams();
-
- const {
- data = { transactionStats: {} } as ServiceNodeStats,
- status,
- } = useFetcher(
- (callApmApi) => {
- if (serviceName && start && end) {
- return callApmApi({
- endpoint: 'GET /api/apm/service-map/service/{serviceName}',
- params: {
- path: { serviceName },
- query: { environment, start, end },
- },
- });
- }
- },
- [environment, serviceName, start, end],
- {
- preservePreviousData: false,
- }
- );
-
- const isLoading = status === FETCH_STATUS.LOADING;
-
- if (isLoading) {
- return ;
- }
-
- const {
- avgCpuUsage,
- avgErrorRate,
- avgMemoryUsage,
- transactionStats: { avgRequestsPerMinute, avgTransactionDuration },
- } = data;
-
- const hasServiceData = [
- avgCpuUsage,
- avgErrorRate,
- avgMemoryUsage,
- avgRequestsPerMinute,
- avgTransactionDuration,
- ].some((stat) => isNumber(stat));
-
- if (!hasServiceData) {
- return (
-
- {i18n.translate('xpack.apm.serviceMap.popoverMetrics.noDataText', {
- defaultMessage: `No data for selected environment. Try switching to another environment.`,
- })}
-
- );
- }
- return (
- <>
- {serviceAnomalyStats && (
- <>
-
-
- >
- )}
-
- >
- );
-}
-
-function LoadingSpinner() {
- return (
-
-
-
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx
deleted file mode 100644
index 766debc6d5587..0000000000000
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx
+++ /dev/null
@@ -1,96 +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
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { i18n } from '@kbn/i18n';
-import { isNumber } from 'lodash';
-import React from 'react';
-import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
-import {
- asDuration,
- asPercent,
- asTransactionRate,
-} from '../../../../../common/utils/formatters';
-import { ServiceNodeStats } from '../../../../../common/service_map';
-
-export const ItemRow = euiStyled('tr')`
- line-height: 2;
-`;
-
-export const ItemTitle = euiStyled('td')`
- color: ${({ theme }) => theme.eui.euiTextSubduedColor};
- padding-right: 1rem;
-`;
-
-export const ItemDescription = euiStyled('td')`
- text-align: right;
-`;
-
-type ServiceStatsListProps = ServiceNodeStats;
-
-export function ServiceStatsList({
- transactionStats,
- avgErrorRate,
- avgCpuUsage,
- avgMemoryUsage,
-}: ServiceStatsListProps) {
- const listItems = [
- {
- title: i18n.translate(
- 'xpack.apm.serviceMap.avgTransDurationPopoverStat',
- {
- defaultMessage: 'Latency (avg.)',
- }
- ),
- description: isNumber(transactionStats.avgTransactionDuration)
- ? asDuration(transactionStats.avgTransactionDuration)
- : null,
- },
- {
- title: i18n.translate(
- 'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
- {
- defaultMessage: 'Throughput (avg.)',
- }
- ),
- description: asTransactionRate(transactionStats.avgRequestsPerMinute),
- },
- {
- title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', {
- defaultMessage: 'Trans. error rate (avg.)',
- }),
- description: asPercent(avgErrorRate, 1, ''),
- },
- {
- title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', {
- defaultMessage: 'CPU usage (avg.)',
- }),
- description: asPercent(avgCpuUsage, 1, ''),
- },
- {
- title: i18n.translate('xpack.apm.serviceMap.avgMemoryUsagePopoverStat', {
- defaultMessage: 'Memory usage (avg.)',
- }),
- description: asPercent(avgMemoryUsage, 1, ''),
- },
- ];
-
- return (
-
-
- {listItems.map(
- ({ title, description }) =>
- description && (
-
- {title}
- {description}
-
- )
- )}
-
-
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx
new file mode 100644
index 0000000000000..e0fef269f3faf
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiButton, EuiFlexItem } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { TypeOf } from '@kbn/typed-react-router-config';
+import React from 'react';
+import { ContentsProps } from '.';
+import { NodeStats } from '../../../../../common/service_map';
+import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
+import { useApmParams } from '../../../../hooks/use_apm_params';
+import { useApmRouter } from '../../../../hooks/use_apm_router';
+import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
+import { ApmRoutes } from '../../../routing/apm_route_config';
+import { StatsList } from './stats_list';
+
+export function BackendContents({ nodeData }: ContentsProps) {
+ const { query } = useApmParams('/*');
+ const apmRouter = useApmRouter();
+ const {
+ urlParams: { environment, start, end },
+ } = useUrlParams();
+
+ const backendName = nodeData.label;
+
+ const { data = { transactionStats: {} } as NodeStats, status } = useFetcher(
+ (callApmApi) => {
+ if (backendName && start && end) {
+ return callApmApi({
+ endpoint: 'GET /api/apm/service-map/backend/{backendName}',
+ params: {
+ path: { backendName },
+ query: {
+ environment,
+ start,
+ end,
+ },
+ },
+ });
+ }
+ },
+ [environment, backendName, start, end],
+ {
+ preservePreviousData: false,
+ }
+ );
+
+ const isLoading = status === FETCH_STATUS.LOADING;
+ const detailsUrl = apmRouter.link('/backends/:backendName/overview', {
+ path: { backendName },
+ query: query as TypeOf<
+ ApmRoutes,
+ '/backends/:backendName/overview'
+ >['query'],
+ });
+
+ return (
+ <>
+
+
+
+
+
+ {i18n.translate('xpack.apm.serviceMap.backendDetailsButtonText', {
+ defaultMessage: 'Backend Details',
+ })}
+
+
+ >
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/externals_list_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/externals_list_contents.tsx
new file mode 100644
index 0000000000000..c8ab7a653b201
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/externals_list_contents.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import {
+ EuiDescriptionList,
+ EuiDescriptionListDescription,
+ EuiDescriptionListTitle,
+ EuiFlexItem,
+} from '@elastic/eui';
+import React, { Fragment } from 'react';
+import { ContentsProps } from '.';
+import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
+import {
+ SPAN_DESTINATION_SERVICE_RESOURCE,
+ SPAN_TYPE,
+ SPAN_SUBTYPE,
+} from '../../../../../common/elasticsearch_fieldnames';
+import { ExternalConnectionNode } from '../../../../../common/service_map';
+
+const ExternalResourcesList = euiStyled.section`
+ max-height: 360px;
+ overflow: auto;
+`;
+
+export function ExternalsListContents({ nodeData }: ContentsProps) {
+ return (
+
+
+
+ {nodeData.groupedConnections.map(
+ (resource: ExternalConnectionNode) => {
+ const title =
+ resource.label || resource[SPAN_DESTINATION_SERVICE_RESOURCE];
+ const desc = `${resource[SPAN_TYPE]} (${resource[SPAN_SUBTYPE]})`;
+ return (
+
+
+ {title}
+
+
+ {desc}
+
+
+ );
+ }
+ )}
+
+
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx
index 4999459b30dcf..df923730a2227 100644
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx
@@ -5,6 +5,14 @@
* 2.0.
*/
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHorizontalRule,
+ EuiPopover,
+ EuiTitle,
+} from '@elastic/eui';
+import cytoscape from 'cytoscape';
import React, {
CSSProperties,
MouseEvent,
@@ -14,13 +22,39 @@ import React, {
useRef,
useState,
} from 'react';
-import { EuiPopover } from '@elastic/eui';
-import cytoscape from 'cytoscape';
+import {
+ SERVICE_NAME,
+ SPAN_TYPE,
+} from '../../../../../common/elasticsearch_fieldnames';
import { useTheme } from '../../../../hooks/use_theme';
-import { SERVICE_NAME } from '../../../../../common/elasticsearch_fieldnames';
import { CytoscapeContext } from '../Cytoscape';
-import { getAnimationOptions } from '../cytoscape_options';
-import { Contents } from './Contents';
+import { getAnimationOptions, popoverWidth } from '../cytoscape_options';
+import { BackendContents } from './backend_contents';
+import { ExternalsListContents } from './externals_list_contents';
+import { ResourceContents } from './resource_contents';
+import { ServiceContents } from './service_contents';
+
+function getContentsComponent(selectedNodeData: cytoscape.NodeDataDefinition) {
+ if (
+ selectedNodeData.groupedConnections &&
+ Array.isArray(selectedNodeData.groupedConnections)
+ ) {
+ return ExternalsListContents;
+ }
+ if (selectedNodeData[SERVICE_NAME]) {
+ return ServiceContents;
+ }
+ if (selectedNodeData[SPAN_TYPE] === 'resource') {
+ return ResourceContents;
+ }
+
+ return BackendContents;
+}
+
+export interface ContentsProps {
+ nodeData: cytoscape.NodeDataDefinition;
+ onFocusClick: (event: MouseEvent) => void;
+}
interface PopoverProps {
focusedServiceName?: string;
@@ -42,7 +76,6 @@ export function Popover({ focusedServiceName }: PopoverProps) {
const renderedWidth = selectedNode?.renderedWidth() ?? 0;
const { x, y } = selectedNode?.renderedPosition() ?? { x: -10000, y: -10000 };
const isOpen = !!selectedNode;
- const isService = selectedNode?.data(SERVICE_NAME) !== undefined;
const triggerStyle: CSSProperties = {
background: 'transparent',
height: renderedHeight,
@@ -58,9 +91,8 @@ export function Popover({ focusedServiceName }: PopoverProps) {
transform: `translate(${x}px, ${translateY}px)`,
};
const selectedNodeData = selectedNode?.data() ?? {};
- const selectedNodeServiceName = selectedNodeData.id;
- const label = selectedNodeData.label || selectedNodeServiceName;
const popoverRef = useRef(null);
+ const selectedNodeId = selectedNodeData.id;
// Set up Cytoscape event handlers
useEffect(() => {
@@ -99,19 +131,21 @@ export function Popover({ focusedServiceName }: PopoverProps) {
if (cy) {
cy.animate({
...getAnimationOptions(theme),
- center: { eles: cy.getElementById(selectedNodeServiceName) },
+ center: { eles: cy.getElementById(selectedNodeId) },
});
}
},
- [cy, selectedNodeServiceName, theme]
+ [cy, selectedNodeId, theme]
);
- const isAlreadyFocused = focusedServiceName === selectedNodeServiceName;
+ const isAlreadyFocused = focusedServiceName === selectedNodeId;
const onFocusClick = isAlreadyFocused
? centerSelectedNode
: (_event: MouseEvent) => deselect();
+ const ContentsComponent = getContentsComponent(selectedNodeData);
+
return (
-
+
+
+
+
+ {selectedNodeData.label ?? selectedNodeId}
+
+
+
+
+
+
);
}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx
new file mode 100644
index 0000000000000..9678258c4740c
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/popover.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { composeStories } from '@storybook/testing-react';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import * as stories from './Popover.stories';
+
+const { Backend, ExternalsList, Resource, Service } = composeStories(stories);
+
+describe('Popover', () => {
+ describe('with backend data', () => {
+ it('renders a backend link', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('link', { name: /backend details/i })
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('with externals list data', () => {
+ it('renders an externals list', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/813-mam-392.mktoresp.com:443/)
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('with resource data', () => {
+ it('renders with no buttons', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('with service data', () => {
+ it('renders contents for a service', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('link', { name: /service details/i })
+ ).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/resource_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/resource_contents.tsx
new file mode 100644
index 0000000000000..e0cfb8f16f61d
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/resource_contents.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import {
+ EuiDescriptionListDescription,
+ EuiDescriptionListTitle,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import type { ContentsProps } from '.';
+import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
+import {
+ SPAN_SUBTYPE,
+ SPAN_TYPE,
+} from '../../../../../common/elasticsearch_fieldnames';
+
+const ItemRow = euiStyled.div`
+ line-height: 2;
+`;
+
+const SubduedDescriptionListTitle = euiStyled(EuiDescriptionListTitle)`
+ &&& {
+ color: ${({ theme }) => theme.eui.euiTextSubduedColor};
+ }
+`;
+
+export function ResourceContents({ nodeData }: ContentsProps) {
+ const subtype = nodeData[SPAN_SUBTYPE];
+ const type = nodeData[SPAN_TYPE];
+
+ const listItems = [
+ {
+ title: i18n.translate('xpack.apm.serviceMap.typePopoverStat', {
+ defaultMessage: 'Type',
+ }),
+ description: type,
+ },
+ {
+ title: i18n.translate('xpack.apm.serviceMap.subtypePopoverStat', {
+ defaultMessage: 'Subtype',
+ }),
+ description: subtype,
+ },
+ ];
+
+ return (
+ <>
+ {listItems.map(
+ ({ title, description }) =>
+ description && (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ )
+ )}
+ >
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx
new file mode 100644
index 0000000000000..b486e5e19fb03
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint-disable @elastic/eui/href-or-on-click */
+
+import { EuiButton, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import type { ContentsProps } from '.';
+import { NodeStats } from '../../../../../common/service_map';
+import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
+import { useApmRouter } from '../../../../hooks/use_apm_router';
+import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
+import { AnomalyDetection } from './anomaly_detection';
+import { StatsList } from './stats_list';
+
+export function ServiceContents({ onFocusClick, nodeData }: ContentsProps) {
+ const apmRouter = useApmRouter();
+
+ const {
+ urlParams: { environment, start, end },
+ } = useUrlParams();
+
+ const serviceName = nodeData.id!;
+
+ const { data = { transactionStats: {} } as NodeStats, status } = useFetcher(
+ (callApmApi) => {
+ if (serviceName && start && end) {
+ return callApmApi({
+ endpoint: 'GET /api/apm/service-map/service/{serviceName}',
+ params: {
+ path: { serviceName },
+ query: { environment, start, end },
+ },
+ });
+ }
+ },
+ [environment, serviceName, start, end],
+ {
+ preservePreviousData: false,
+ }
+ );
+
+ const isLoading = status === FETCH_STATUS.LOADING;
+
+ const detailsUrl = apmRouter.link('/services/:serviceName', {
+ path: { serviceName },
+ });
+
+ const focusUrl = apmRouter.link('/services/:serviceName/service-map', {
+ path: { serviceName },
+ });
+
+ const { serviceAnomalyStats } = nodeData;
+
+ return (
+ <>
+
+ {serviceAnomalyStats && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ {i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', {
+ defaultMessage: 'Service Details',
+ })}
+
+
+
+
+ {i18n.translate('xpack.apm.serviceMap.focusMapButtonText', {
+ defaultMessage: 'Focus map',
+ })}
+
+
+ >
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx
deleted file mode 100644
index f1a89043f826e..0000000000000
--- a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx
+++ /dev/null
@@ -1,56 +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
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import { ServiceStatsList } from './ServiceStatsList';
-
-export default {
- title: 'app/ServiceMap/Popover/ServiceStatsList',
- component: ServiceStatsList,
-};
-
-export function Example() {
- return (
-
- );
-}
-
-export function SomeNullValues() {
- return (
-
- );
-}
-
-export function AllNullValues() {
- return (
-
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx
new file mode 100644
index 0000000000000..88915b9bc9f34
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx
@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiFlexGroup, EuiLoadingSpinner, EuiText } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { isNumber } from 'lodash';
+import React from 'react';
+import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
+import { NodeStats } from '../../../../../common/service_map';
+import {
+ asDuration,
+ asPercent,
+ asTransactionRate,
+} from '../../../../../common/utils/formatters';
+
+export const ItemRow = euiStyled.tr`
+ line-height: 2;
+`;
+
+export const ItemTitle = euiStyled.td`
+ color: ${({ theme }) => theme.eui.euiTextSubduedColor};
+ padding-right: 1rem;
+`;
+
+export const ItemDescription = euiStyled.td`
+ text-align: right;
+`;
+
+function LoadingSpinner() {
+ return (
+
+
+
+ );
+}
+
+function NoDataMessage() {
+ return (
+
+ {i18n.translate('xpack.apm.serviceMap.popover.noDataText', {
+ defaultMessage: `No data for selected environment. Try switching to another environment.`,
+ })}
+
+ );
+}
+
+interface StatsListProps {
+ isLoading: boolean;
+ data: NodeStats;
+}
+
+export function StatsList({ data, isLoading }: StatsListProps) {
+ const {
+ avgCpuUsage,
+ avgErrorRate,
+ avgMemoryUsage,
+ transactionStats: { avgRequestsPerMinute, avgTransactionDuration },
+ } = data;
+
+ const hasData = [
+ avgCpuUsage,
+ avgErrorRate,
+ avgMemoryUsage,
+ avgRequestsPerMinute,
+ avgTransactionDuration,
+ ].some((stat) => isNumber(stat));
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!hasData) {
+ return ;
+ }
+
+ const items = [
+ {
+ title: i18n.translate(
+ 'xpack.apm.serviceMap.avgTransDurationPopoverStat',
+ {
+ defaultMessage: 'Latency (avg.)',
+ }
+ ),
+ description: isNumber(avgTransactionDuration)
+ ? asDuration(avgTransactionDuration)
+ : null,
+ },
+ {
+ title: i18n.translate(
+ 'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
+ {
+ defaultMessage: 'Throughput (avg.)',
+ }
+ ),
+ description: asTransactionRate(avgRequestsPerMinute),
+ },
+ {
+ title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', {
+ defaultMessage: 'Trans. error rate (avg.)',
+ }),
+ description: asPercent(avgErrorRate, 1, ''),
+ },
+ {
+ title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', {
+ defaultMessage: 'CPU usage (avg.)',
+ }),
+ description: asPercent(avgCpuUsage, 1, ''),
+ },
+ {
+ title: i18n.translate('xpack.apm.serviceMap.avgMemoryUsagePopoverStat', {
+ defaultMessage: 'Memory usage (avg.)',
+ }),
+ description: asPercent(avgMemoryUsage, 1, ''),
+ },
+ ];
+
+ return (
+
+
+ {items.map(({ title, description }) => {
+ return description ? (
+
+ {title}
+ {description}
+
+ ) : null;
+ })}
+
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts b/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts
index 93021fe177f6b..2abb7d03bce80 100644
--- a/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts
+++ b/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts
@@ -40,6 +40,7 @@ export const spanTypeIcons: {
aws: {
servicename: awsIcon,
},
+ cache: { redis: redisIcon },
db: {
cassandra: cassandraIcon,
cosmosdb: azureIcon,
diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_backend_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_backend_node_info.ts
new file mode 100644
index 0000000000000..17c3191d80ff4
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_backend_node_info.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { rangeQuery } from '../../../../observability/server';
+import {
+ EVENT_OUTCOME,
+ SPAN_DESTINATION_SERVICE_RESOURCE,
+ SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
+ SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
+} from '../../../common/elasticsearch_fieldnames';
+import { EventOutcome } from '../../../common/event_outcome';
+import { ProcessorEvent } from '../../../common/processor_event';
+import { environmentQuery } from '../../../common/utils/environment_query';
+import { withApmSpan } from '../../utils/with_apm_span';
+import { calculateThroughput } from '../helpers/calculate_throughput';
+import { Setup, SetupTimeRange } from '../helpers/setup_request';
+
+interface Options {
+ setup: Setup & SetupTimeRange;
+ environment?: string;
+ backendName: string;
+}
+
+export function getServiceMapBackendNodeInfo({
+ environment,
+ backendName,
+ setup,
+}: Options) {
+ return withApmSpan('get_service_map_backend_node_stats', async () => {
+ const { apmEventClient, start, end } = setup;
+
+ const response = await apmEventClient.search(
+ 'get_service_map_backend_node_stats',
+ {
+ apm: {
+ events: [ProcessorEvent.metric],
+ },
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName } },
+ ...rangeQuery(start, end),
+ ...environmentQuery(environment),
+ ],
+ },
+ },
+ aggs: {
+ latency_sum: {
+ sum: {
+ field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
+ },
+ },
+ count: {
+ sum: {
+ field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
+ },
+ },
+ [EVENT_OUTCOME]: {
+ terms: { field: EVENT_OUTCOME, include: [EventOutcome.failure] },
+ },
+ },
+ },
+ }
+ );
+
+ const count = response.aggregations?.count.value ?? 0;
+ const errorCount =
+ response.aggregations?.[EVENT_OUTCOME].buckets[0]?.doc_count ?? 0;
+ const latencySum = response.aggregations?.latency_sum.value ?? 0;
+
+ const avgErrorRate = errorCount / count;
+ const avgTransactionDuration = latencySum / count;
+ const avgRequestsPerMinute = calculateThroughput({
+ start,
+ end,
+ value: count,
+ });
+
+ if (count === 0) {
+ return {
+ avgErrorRate: null,
+ transactionStats: {
+ avgRequestsPerMinute: null,
+ avgTransactionDuration: null,
+ },
+ };
+ }
+
+ return {
+ avgErrorRate,
+ transactionStats: {
+ avgRequestsPerMinute,
+ avgTransactionDuration,
+ },
+ };
+ });
+}
diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts
index 267479de4c102..023753932d21f 100644
--- a/x-pack/plugins/apm/server/routes/service_map.ts
+++ b/x-pack/plugins/apm/server/routes/service_map.ts
@@ -7,16 +7,17 @@
import Boom from '@hapi/boom';
import * as t from 'io-ts';
+import { isActivePlatinumLicense } from '../../common/license_check';
import { invalidLicenseMessage } from '../../common/service_map';
+import { notifyFeatureUsage } from '../feature';
+import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
import { setupRequest } from '../lib/helpers/setup_request';
import { getServiceMap } from '../lib/service_map/get_service_map';
+import { getServiceMapBackendNodeInfo } from '../lib/service_map/get_service_map_backend_node_info';
import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info';
import { createApmServerRoute } from './create_apm_server_route';
-import { environmentRt, rangeRt } from './default_api_types';
-import { notifyFeatureUsage } from '../feature';
-import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
-import { isActivePlatinumLicense } from '../../common/license_check';
import { createApmServerRouteRepository } from './create_apm_server_route_repository';
+import { environmentRt, rangeRt } from './default_api_types';
const serviceMapRoute = createApmServerRoute({
endpoint: 'GET /api/apm/service-map',
@@ -100,6 +101,40 @@ const serviceMapServiceNodeRoute = createApmServerRoute({
},
});
+const serviceMapBackendNodeRoute = createApmServerRoute({
+ endpoint: 'GET /api/apm/service-map/backend/{backendName}',
+ params: t.type({
+ path: t.type({
+ backendName: t.string,
+ }),
+ query: t.intersection([environmentRt, rangeRt]),
+ }),
+ options: { tags: ['access:apm'] },
+ handler: async (resources) => {
+ const { config, context, params } = resources;
+
+ if (!config['xpack.apm.serviceMapEnabled']) {
+ throw Boom.notFound();
+ }
+ if (!isActivePlatinumLicense(context.licensing.license)) {
+ throw Boom.forbidden(invalidLicenseMessage);
+ }
+ const setup = await setupRequest(resources);
+
+ const {
+ path: { backendName },
+ query: { environment },
+ } = params;
+
+ return getServiceMapBackendNodeInfo({
+ environment,
+ setup,
+ backendName,
+ });
+ },
+});
+
export const serviceMapRouteRepository = createApmServerRouteRepository()
.add(serviceMapRoute)
- .add(serviceMapServiceNodeRoute);
+ .add(serviceMapServiceNodeRoute)
+ .add(serviceMapBackendNodeRoute);
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 6282f38ab6eaa..7db3d50b931bc 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -5893,7 +5893,6 @@
"xpack.apm.serviceMap.invalidLicenseMessage": "サービスマップを利用するには、Elastic Platinum ライセンスが必要です。これにより、APM データとともにアプリケーションスタックすべてを可視化することができるようになります。",
"xpack.apm.serviceMap.noServicesPromptDescription": "現在選択されている時間範囲と環境内では、マッピングするサービスが見つかりません。別の範囲を試すか、選択した環境を確認してください。サービスがない場合は、セットアップ手順に従って開始してください。",
"xpack.apm.serviceMap.noServicesPromptTitle": "サービスが利用できません",
- "xpack.apm.serviceMap.popoverMetrics.noDataText": "選択した環境のデータがありません。別の環境に切り替えてください。",
"xpack.apm.serviceMap.resourceCountLabel": "{count}個のリソース",
"xpack.apm.serviceMap.serviceDetailsButtonText": "サービス詳細",
"xpack.apm.serviceMap.subtypePopoverStat": "サブタイプ",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 7ee5525aa1699..487c5443574b1 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -5928,7 +5928,6 @@
"xpack.apm.serviceMap.invalidLicenseMessage": "要访问服务地图,必须订阅 Elastic 白金级许可。使用该许可证,您将能够可视化整个应用程序堆栈以及 APM 数据。",
"xpack.apm.serviceMap.noServicesPromptDescription": "我们在当前选择的时间范围和环境内找不到任何要映射的服务。请尝试其他范围或检查选定环境。如果您未使用任何服务,请使用我们的设置说明以开始。",
"xpack.apm.serviceMap.noServicesPromptTitle": "没有可用服务",
- "xpack.apm.serviceMap.popoverMetrics.noDataText": "选定的环境没有数据。请尝试切换到其他环境。",
"xpack.apm.serviceMap.resourceCountLabel": "{count} 项资源",
"xpack.apm.serviceMap.serviceDetailsButtonText": "服务详情",
"xpack.apm.serviceMap.subtypePopoverStat": "子类型",
diff --git a/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts b/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts
index b043efe05b523..0461ac8e4821a 100644
--- a/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts
+++ b/x-pack/test/apm_api_integration/tests/service_maps/service_maps.ts
@@ -55,11 +55,39 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
expect(response.status).to.be(200);
- expect(response.body.avgCpuUsage).to.be(null);
- expect(response.body.avgErrorRate).to.be(null);
- expect(response.body.avgMemoryUsage).to.be(null);
- expect(response.body.transactionStats.avgRequestsPerMinute).to.be(null);
- expect(response.body.transactionStats.avgTransactionDuration).to.be(null);
+ expectSnapshot(response.body).toMatchInline(`
+ Object {
+ "avgCpuUsage": null,
+ "avgErrorRate": null,
+ "avgMemoryUsage": null,
+ "transactionStats": Object {
+ "avgRequestsPerMinute": null,
+ "avgTransactionDuration": null,
+ },
+ }
+ `);
+ });
+ });
+
+ describe('/api/apm/service-map/backend/{backendName}', () => {
+ it('returns an object with nulls', async () => {
+ const q = querystring.stringify({
+ start: metadata.start,
+ end: metadata.end,
+ });
+ const response = await supertest.get(`/api/apm/service-map/backend/postgres?${q}`);
+
+ expect(response.status).to.be(200);
+
+ expectSnapshot(response.body).toMatchInline(`
+ Object {
+ "avgErrorRate": null,
+ "transactionStats": Object {
+ "avgRequestsPerMinute": null,
+ "avgTransactionDuration": null,
+ },
+ }
+ `);
});
});
});
@@ -243,5 +271,51 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
});
});
+
+ describe('/api/apm/service-map/service/{serviceName}', () => {
+ it('returns an object with data', async () => {
+ const q = querystring.stringify({
+ start: metadata.start,
+ end: metadata.end,
+ });
+ const response = await supertest.get(`/api/apm/service-map/service/opbeans-node?${q}`);
+
+ expect(response.status).to.be(200);
+
+ expectSnapshot(response.body).toMatchInline(`
+ Object {
+ "avgCpuUsage": 0.240216666666667,
+ "avgErrorRate": 0,
+ "avgMemoryUsage": 0.202572668763642,
+ "transactionStats": Object {
+ "avgRequestsPerMinute": 5.2,
+ "avgTransactionDuration": 53906.6603773585,
+ },
+ }
+ `);
+ });
+ });
+
+ describe('/api/apm/service-map/backend/{backendName}', () => {
+ it('returns an object with data', async () => {
+ const q = querystring.stringify({
+ start: metadata.start,
+ end: metadata.end,
+ });
+ const response = await supertest.get(`/api/apm/service-map/backend/postgresql?${q}`);
+
+ expect(response.status).to.be(200);
+
+ expectSnapshot(response.body).toMatchInline(`
+ Object {
+ "avgErrorRate": 0,
+ "transactionStats": Object {
+ "avgRequestsPerMinute": 82.9666666666667,
+ "avgTransactionDuration": 18307.583366814,
+ },
+ }
+ `);
+ });
+ });
});
}