diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 303f6b02c0ea2..b8e6922414ebf 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -58,9 +58,9 @@ export interface Connection { destination: ConnectionNode; } -export interface ServiceNodeStats { - avgMemoryUsage: number | null; - avgCpuUsage: number | null; +export interface NodeStats { + avgMemoryUsage?: number | null; + avgCpuUsage?: number | null; transactionStats: { avgTransactionDuration: number | null; avgRequestsPerMinute: number | null; diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx deleted file mode 100644 index f6376d201f2e1..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx +++ /dev/null @@ -1,41 +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, { ReactNode } from 'react'; -import { Buttons } from './Buttons'; -import { render } from '@testing-library/react'; -import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; - -function Wrapper({ children }: { children?: ReactNode }) { - return {children}; -} - -describe('Popover Buttons', () => { - it('renders', () => { - expect(() => - render(, { - wrapper: Wrapper, - }) - ).not.toThrowError(); - }); - - it('handles focus click', async () => { - const onFocusClick = jest.fn(); - const result = render( - , - { wrapper: Wrapper } - ); - const focusButton = await result.findByText('Focus map'); - - focusButton.click(); - - expect(onFocusClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx deleted file mode 100644 index c4ff430e23fe8..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx +++ /dev/null @@ -1,60 +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. - */ - -/* eslint-disable @elastic/eui/href-or-on-click */ - -import { EuiButton, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { MouseEvent } from 'react'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { getAPMHref } from '../../../shared/Links/apm/APMLink'; -import { APMQueryParams } from '../../../shared/Links/url_helpers'; - -interface ButtonsProps { - onFocusClick?: (event: MouseEvent) => void; - selectedNodeServiceName: string; -} - -export function Buttons({ - onFocusClick = () => {}, - selectedNodeServiceName, -}: ButtonsProps) { - const { core } = useApmPluginContext(); - const { basePath } = core.http; - const urlParams = useUrlParams().urlParams as APMQueryParams; - - const detailsUrl = getAPMHref({ - basePath, - path: `/services/${selectedNodeServiceName}`, - query: urlParams, - }); - const focusUrl = getAPMHref({ - basePath, - path: `/services/${selectedNodeServiceName}/service-map`, - query: urlParams, - }); - - return ( - <> - - - {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/Contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx deleted file mode 100644 index 9030aeed7b13d..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx +++ /dev/null @@ -1,91 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiTitle, -} from '@elastic/eui'; -import cytoscape from 'cytoscape'; -import React, { MouseEvent } from 'react'; -import { Buttons } from './Buttons'; -import { Info } from './Info'; -import { ServiceStatsFetcher } from './ServiceStatsFetcher'; -import { popoverWidth } from '../cytoscape_options'; - -interface ContentsProps { - isService: boolean; - label: string; - onFocusClick: (event: MouseEvent) => 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, + }, + } + `); + }); + }); }); }