[APM] Updated header icons (#84760)

* creating service name header

* fixing icons

* removing unused api import

* fixing some stuff

* adding API tests

* refactoring some stuff

* fixing tests

* refactoring some stuff

* fixing i18n

* reverting

* renaming

* applying min width

* addressing PR comments and adding test

* sorting service version

* changing sort type to desc

* addressing pr comments

* changing to show total and not avg

* addressing pr comments

* addressing pr comments

* addressing pr comments

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2020-12-16 23:08:17 +01:00 committed by GitHub
parent 1fddf94274
commit 53da425c8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1423 additions and 6 deletions

View file

@ -1,5 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error AGENT 1`] = `
Object {
"name": "java",
"version": "agent version",
}
`;
exports[`Error AGENT_NAME 1`] = `"java"`;
exports[`Error AGENT_VERSION 1`] = `"agent version"`;
@ -8,14 +15,26 @@ exports[`Error CLIENT_GEO 1`] = `undefined`;
exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Error CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",
"provider": "gcp",
"region": "europe-west1",
}
`;
exports[`Error CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Error CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Error CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Error CLS_FIELD 1`] = `undefined`;
exports[`Error CONTAINER 1`] = `undefined`;
exports[`Error CONTAINER_ID 1`] = `undefined`;
exports[`Error DESTINATION_ADDRESS 1`] = `undefined`;
@ -42,12 +61,20 @@ exports[`Error FCP_FIELD 1`] = `undefined`;
exports[`Error FID_FIELD 1`] = `undefined`;
exports[`Error HOST 1`] = `
Object {
"hostname": "my hostname",
}
`;
exports[`Error HOST_NAME 1`] = `"my hostname"`;
exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`;
exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Error KUBERNETES 1`] = `undefined`;
exports[`Error LABEL_NAME 1`] = `undefined`;
exports[`Error LCP_FIELD 1`] = `undefined`;
@ -94,6 +121,16 @@ exports[`Error POD_NAME 1`] = `undefined`;
exports[`Error PROCESSOR_EVENT 1`] = `"error"`;
exports[`Error SERVICE 1`] = `
Object {
"language": Object {
"name": "nodejs",
"version": "v1337",
},
"name": "service name",
}
`;
exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`;
exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`;
@ -176,6 +213,13 @@ exports[`Error USER_AGENT_OS 1`] = `undefined`;
exports[`Error USER_ID 1`] = `undefined`;
exports[`Span AGENT 1`] = `
Object {
"name": "java",
"version": "agent version",
}
`;
exports[`Span AGENT_NAME 1`] = `"java"`;
exports[`Span AGENT_VERSION 1`] = `"agent version"`;
@ -184,14 +228,26 @@ exports[`Span CLIENT_GEO 1`] = `undefined`;
exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Span CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",
"provider": "gcp",
"region": "europe-west1",
}
`;
exports[`Span CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Span CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Span CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Span CLS_FIELD 1`] = `undefined`;
exports[`Span CONTAINER 1`] = `undefined`;
exports[`Span CONTAINER_ID 1`] = `undefined`;
exports[`Span DESTINATION_ADDRESS 1`] = `undefined`;
@ -218,12 +274,16 @@ exports[`Span FCP_FIELD 1`] = `undefined`;
exports[`Span FID_FIELD 1`] = `undefined`;
exports[`Span HOST 1`] = `undefined`;
exports[`Span HOST_NAME 1`] = `undefined`;
exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`;
exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Span KUBERNETES 1`] = `undefined`;
exports[`Span LABEL_NAME 1`] = `undefined`;
exports[`Span LCP_FIELD 1`] = `undefined`;
@ -270,6 +330,12 @@ exports[`Span POD_NAME 1`] = `undefined`;
exports[`Span PROCESSOR_EVENT 1`] = `"span"`;
exports[`Span SERVICE 1`] = `
Object {
"name": "service name",
}
`;
exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`;
exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`;
@ -352,6 +418,13 @@ exports[`Span USER_AGENT_OS 1`] = `undefined`;
exports[`Span USER_ID 1`] = `undefined`;
exports[`Transaction AGENT 1`] = `
Object {
"name": "java",
"version": "agent version",
}
`;
exports[`Transaction AGENT_NAME 1`] = `"java"`;
exports[`Transaction AGENT_VERSION 1`] = `"agent version"`;
@ -360,14 +433,26 @@ exports[`Transaction CLIENT_GEO 1`] = `undefined`;
exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`;
exports[`Transaction CLOUD 1`] = `
Object {
"availability_zone": "europe-west1-c",
"provider": "gcp",
"region": "europe-west1",
}
`;
exports[`Transaction CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Transaction CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`;
exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Transaction CLS_FIELD 1`] = `undefined`;
exports[`Transaction CONTAINER 1`] = `"container1234567890abcdef"`;
exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`;
exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`;
@ -394,12 +479,26 @@ exports[`Transaction FCP_FIELD 1`] = `undefined`;
exports[`Transaction FID_FIELD 1`] = `undefined`;
exports[`Transaction HOST 1`] = `
Object {
"hostname": "my hostname",
}
`;
exports[`Transaction HOST_NAME 1`] = `"my hostname"`;
exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`;
exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`;
exports[`Transaction KUBERNETES 1`] = `
Object {
"pod": Object {
"uid": "pod1234567890abcdef",
},
}
`;
exports[`Transaction LABEL_NAME 1`] = `undefined`;
exports[`Transaction LCP_FIELD 1`] = `undefined`;
@ -446,6 +545,16 @@ exports[`Transaction POD_NAME 1`] = `undefined`;
exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`;
exports[`Transaction SERVICE 1`] = `
Object {
"language": Object {
"name": "nodejs",
"version": "v1337",
},
"name": "service name",
}
`;
exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`;
exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`;

View file

@ -4,10 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const CLOUD = 'cloud';
export const CLOUD_AVAILABILITY_ZONE = 'cloud.availability_zone';
export const CLOUD_PROVIDER = 'cloud.provider';
export const CLOUD_REGION = 'cloud.region';
export const CLOUD_MACHINE_TYPE = 'cloud.machine.type';
export const SERVICE = 'service';
export const SERVICE_NAME = 'service.name';
export const SERVICE_ENVIRONMENT = 'service.environment';
export const SERVICE_FRAMEWORK_NAME = 'service.framework.name';
@ -19,6 +22,7 @@ export const SERVICE_RUNTIME_VERSION = 'service.runtime.version';
export const SERVICE_NODE_NAME = 'service.node.name';
export const SERVICE_VERSION = 'service.version';
export const AGENT = 'agent';
export const AGENT_NAME = 'agent.name';
export const AGENT_VERSION = 'agent.version';
@ -102,8 +106,11 @@ export const METRIC_JAVA_GC_TIME = 'jvm.gc.time';
export const LABEL_NAME = 'labels.name';
export const HOST = 'host';
export const HOST_NAME = 'host.hostname';
export const CONTAINER = 'container.id';
export const CONTAINER_ID = 'container.id';
export const KUBERNETES = 'kubernetes';
export const POD_NAME = 'kubernetes.pod.name';
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type ContainerType = 'Kubernetes' | 'Docker' | undefined;

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiTitle } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { ApmHeader } from '../../shared/ApmHeader';
import { ServiceIcons } from './service_icons';
import { ServiceDetailTabs } from './service_detail_tabs';
interface Props extends RouteComponentProps<{ serviceName: string }> {
@ -20,9 +21,16 @@ export function ServiceDetails({ match, tab }: Props) {
return (
<div>
<ApmHeader>
<EuiTitle>
<h1>{serviceName}</h1>
</EuiTitle>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>{serviceName}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIcons serviceName={serviceName} />
</EuiFlexItem>
</EuiFlexGroup>
</ApmHeader>
<ServiceDetailTabs serviceName={serviceName} tab={tab} />
</div>

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBadge, EuiDescriptionList } from '@elastic/eui';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { APIReturnType } from '../../../../services/rest/createCallApmApi';
type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>;
interface Props {
cloud: ServiceDetailsReturnType['cloud'];
}
export function CloudDetails({ cloud }: Props) {
if (!cloud) {
return null;
}
const listItems: EuiDescriptionListProps['listItems'] = [];
if (cloud.provider) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel',
{
defaultMessage: 'Cloud provider',
}
),
description: cloud.provider,
});
}
if (!!cloud.availabilityZones?.length) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel',
{
defaultMessage:
'{zones, plural, =0 {Availability zone} one {Availability zone} other {Availability zones}} ',
values: { zones: cloud.availabilityZones.length },
}
),
description: (
<ul>
{cloud.availabilityZones.map((zone, index) => (
<li key={index}>
<EuiBadge color="hollow">{zone}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (cloud.machineTypes) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel',
{
defaultMessage:
'{machineTypes, plural, =0{Machine type} one {Machine type} other {Machine types}} ',
values: { machineTypes: cloud.machineTypes.length },
}
),
description: (
<ul>
{cloud.machineTypes.map((type, index) => (
<li key={index}>
<EuiBadge color="hollow">{type}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (cloud.projectName) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel',
{
defaultMessage: 'Project ID',
}
),
description: cloud.projectName,
});
}
return <EuiDescriptionList textStyle="reverse" listItems={listItems} />;
}

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDescriptionList } from '@elastic/eui';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { asInteger } from '../../../../../common/utils/formatters';
import { APIReturnType } from '../../../../services/rest/createCallApmApi';
type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>;
interface Props {
container: ServiceDetailsReturnType['container'];
}
export function ContainerDetails({ container }: Props) {
if (!container) {
return null;
}
const listItems: EuiDescriptionListProps['listItems'] = [];
if (container.os) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.osLabel',
{
defaultMessage: 'OS',
}
),
description: container.os,
});
}
if (container.isContainerized !== undefined) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel',
{ defaultMessage: 'Containerized' }
),
description: container.isContainerized
? i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.yesLabel',
{
defaultMessage: 'Yes',
}
)
: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.noLabel',
{
defaultMessage: 'No',
}
),
});
}
if (container.totalNumberInstances) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel',
{ defaultMessage: 'Total number of instances' }
),
description: asInteger(container.totalNumberInstances),
});
}
if (container.type) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel',
{ defaultMessage: 'Orchestration' }
),
description: container.type,
});
}
return <EuiDescriptionList textStyle="reverse" listItems={listItems} />;
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButtonEmpty,
EuiIcon,
EuiLoadingContent,
EuiPopover,
EuiPopoverTitle,
} from '@elastic/eui';
import React from 'react';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { px } from '../../../../style/variables';
interface IconPopoverProps {
title: string;
children: React.ReactChild;
onOpen: () => void;
onClose: () => void;
detailsFetchStatus: FETCH_STATUS;
isOpen: boolean;
icon?: string;
}
export function IconPopover({
icon,
title,
children,
onOpen,
onClose,
detailsFetchStatus,
isOpen,
}: IconPopoverProps) {
if (!icon) {
return null;
}
const isLoading =
detailsFetchStatus === FETCH_STATUS.LOADING ||
detailsFetchStatus === FETCH_STATUS.PENDING;
return (
<EuiPopover
anchorPosition="downCenter"
ownFocus={false}
button={
<EuiButtonEmpty onClick={onOpen} data-test-subj={`popover_${title}`}>
<EuiIcon type={icon} size="l" color="black" />
</EuiButtonEmpty>
}
isOpen={isOpen}
closePopover={onClose}
>
<EuiPopoverTitle>{title}</EuiPopoverTitle>
<div style={{ minWidth: px(300) }}>
{isLoading ? (
<EuiLoadingContent data-test-subj="loading-content" />
) : (
children
)}
</div>
</EuiPopover>
);
}

View file

@ -0,0 +1,232 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { fireEvent, render } from '@testing-library/react';
import { CoreStart } from 'kibana/public';
import { merge } from 'lodash';
// import { renderWithTheme } from '../../../../utils/testHelpers';
import React, { ReactNode } from 'react';
import { createKibanaReactContext } from 'src/plugins/kibana_react/public';
import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider';
import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context';
import {
mockApmPluginContextValue,
MockApmPluginContextWrapper,
} from '../../../../context/apm_plugin/mock_apm_plugin_context';
import * as fetcherHook from '../../../../hooks/use_fetcher';
import { ServiceIcons } from './';
const KibanaReactContext = createKibanaReactContext({
usageCollection: { reportUiCounter: () => {} },
} as Partial<CoreStart>);
const addWarning = jest.fn();
const httpGet = jest.fn();
function Wrapper({ children }: { children?: ReactNode }) {
const mockPluginContext = (merge({}, mockApmPluginContextValue, {
core: { http: { get: httpGet }, notifications: { toasts: { addWarning } } },
}) as unknown) as ApmPluginContextValue;
return (
<KibanaReactContext.Provider>
<MockApmPluginContextWrapper value={mockPluginContext}>
<MockUrlParamsContextProvider
params={{
rangeFrom: 'now-15m',
rangeTo: 'now',
start: 'mystart',
end: 'myend',
}}
>
{children}
</MockUrlParamsContextProvider>
</MockApmPluginContextWrapper>
</KibanaReactContext.Provider>
);
}
describe('ServiceIcons', () => {
describe('icons', () => {
it('Shows loading spinner while fetching data', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: undefined,
status: fetcherHook.FETCH_STATUS.LOADING,
refetch: jest.fn(),
});
const { getByTestId, queryAllByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(getByTestId('loading')).toBeInTheDocument();
expect(queryAllByTestId('service')).toHaveLength(0);
expect(queryAllByTestId('container')).toHaveLength(0);
expect(queryAllByTestId('cloud')).toHaveLength(0);
});
it("doesn't show any icons", () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: {},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
const { queryAllByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(queryAllByTestId('service')).toHaveLength(0);
expect(queryAllByTestId('container')).toHaveLength(0);
expect(queryAllByTestId('cloud')).toHaveLength(0);
});
it('shows service icon', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: {
agentName: 'java',
},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
const { queryAllByTestId, getByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(getByTestId('service')).toBeInTheDocument();
expect(queryAllByTestId('container')).toHaveLength(0);
expect(queryAllByTestId('cloud')).toHaveLength(0);
});
it('shows service and container icons', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: {
agentName: 'java',
containerType: 'Kubernetes',
},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
const { queryAllByTestId, getByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(queryAllByTestId('cloud')).toHaveLength(0);
expect(getByTestId('service')).toBeInTheDocument();
expect(getByTestId('container')).toBeInTheDocument();
});
it('shows service, container and cloud icons', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: {
agentName: 'java',
containerType: 'Kubernetes',
cloudProvider: 'gcp',
},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
const { queryAllByTestId, getByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(getByTestId('service')).toBeInTheDocument();
expect(getByTestId('container')).toBeInTheDocument();
expect(getByTestId('cloud')).toBeInTheDocument();
});
});
describe('details', () => {
const callApmApi = (apisMockData: Record<string, object>) => ({
endpoint,
}: {
endpoint: string;
}) => {
return apisMockData[endpoint];
};
it('Shows loading spinner while fetching data', () => {
const apisMockData = {
'GET /api/apm/services/{serviceName}/metadata/icons': {
data: {
agentName: 'java',
containerType: 'Kubernetes',
cloudProvider: 'gcp',
},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
},
'GET /api/apm/services/{serviceName}/metadata/details': {
data: undefined,
status: fetcherHook.FETCH_STATUS.LOADING,
refetch: jest.fn(),
},
};
jest
.spyOn(fetcherHook, 'useFetcher')
.mockImplementation((func: Function, deps: string[]) => {
return func(callApmApi(apisMockData)) || {};
});
const { queryAllByTestId, getByTestId } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(getByTestId('service')).toBeInTheDocument();
expect(getByTestId('container')).toBeInTheDocument();
expect(getByTestId('cloud')).toBeInTheDocument();
fireEvent.click(getByTestId('popover_Service'));
expect(getByTestId('loading-content')).toBeInTheDocument();
});
it('shows service content', () => {
const apisMockData = {
'GET /api/apm/services/{serviceName}/metadata/icons': {
data: {
agentName: 'java',
containerType: 'Kubernetes',
cloudProvider: 'gcp',
},
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
},
'GET /api/apm/services/{serviceName}/metadata/details': {
data: { service: { versions: ['v1.0.0'] } },
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
},
};
jest
.spyOn(fetcherHook, 'useFetcher')
.mockImplementation((func: Function, deps: string[]) => {
return func(callApmApi(apisMockData)) || {};
});
const { queryAllByTestId, getByTestId, getByText } = render(
<Wrapper>
<ServiceIcons serviceName="foo" />
</Wrapper>
);
expect(queryAllByTestId('loading')).toHaveLength(0);
expect(getByTestId('service')).toBeInTheDocument();
expect(getByTestId('container')).toBeInTheDocument();
expect(getByTestId('cloud')).toBeInTheDocument();
fireEvent.click(getByTestId('popover_Service'));
expect(queryAllByTestId('loading-content')).toHaveLength(0);
expect(getByText('Service')).toBeInTheDocument();
expect(getByText('v1.0.0')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ReactChild, useState } from 'react';
import { ContainerType } from '../../../../../common/service_metadata';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon';
import { CloudDetails } from './cloud_details';
import { ContainerDetails } from './container_details';
import { IconPopover } from './icon_popover';
import { ServiceDetails } from './service_details';
interface Props {
serviceName: string;
}
const cloudIcons: Record<string, string> = {
gcp: 'logoGCP',
aws: 'logoAWS',
azure: 'logoAzure',
};
function getCloudIcon(provider?: string) {
if (provider) {
return cloudIcons[provider];
}
}
function getContainerIcon(container?: ContainerType) {
if (!container) {
return;
}
switch (container) {
case 'Kubernetes':
return 'logoKubernetes';
default:
return 'logoDocker';
}
}
type Icons = 'service' | 'container' | 'cloud';
interface PopoverItem {
key: Icons;
icon?: string;
isVisible: boolean;
title: string;
component: ReactChild;
}
export function ServiceIcons({ serviceName }: Props) {
const {
urlParams: { start, end },
uiFilters,
} = useUrlParams();
const [
selectedIconPopover,
setSelectedIconPopover,
] = useState<Icons | null>();
const { data: icons, status: iconsFetchStatus } = useFetcher(
(callApmApi) => {
if (serviceName && start && end) {
return callApmApi({
endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons',
params: {
path: { serviceName },
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
},
});
}
},
[serviceName, start, end, uiFilters]
);
const { data: details, status: detailsFetchStatus } = useFetcher(
(callApmApi) => {
if (selectedIconPopover && serviceName && start && end) {
return callApmApi({
endpoint: 'GET /api/apm/services/{serviceName}/metadata/details',
params: {
path: { serviceName },
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
},
});
}
},
[selectedIconPopover, serviceName, start, end, uiFilters]
);
const isLoading =
!icons &&
(iconsFetchStatus === FETCH_STATUS.LOADING ||
iconsFetchStatus === FETCH_STATUS.PENDING);
if (isLoading) {
return <EuiLoadingSpinner data-test-subj="loading" />;
}
const popoverItems: PopoverItem[] = [
{
key: 'service',
icon: getAgentIcon(icons?.agentName) || 'node',
isVisible: !!icons?.agentName,
title: i18n.translate('xpack.apm.serviceIcons.service', {
defaultMessage: 'Service',
}),
component: <ServiceDetails service={details?.service} />,
},
{
key: 'container',
icon: getContainerIcon(icons?.containerType),
isVisible: !!icons?.containerType,
title: i18n.translate('xpack.apm.serviceIcons.container', {
defaultMessage: 'Container',
}),
component: <ContainerDetails container={details?.container} />,
},
{
key: 'cloud',
icon: getCloudIcon(icons?.cloudProvider),
isVisible: !!icons?.cloudProvider,
title: i18n.translate('xpack.apm.serviceIcons.cloud', {
defaultMessage: 'Cloud',
}),
component: <CloudDetails cloud={details?.cloud} />,
},
];
return (
<EuiFlexGroup gutterSize="s" responsive={false}>
{popoverItems.map((item) => {
if (item.isVisible) {
return (
<EuiFlexItem grow={false} data-test-subj={item.key} key={item.key}>
<IconPopover
isOpen={selectedIconPopover === item.key}
icon={item.icon}
detailsFetchStatus={detailsFetchStatus}
title={item.title}
onOpen={() => {
setSelectedIconPopover(item.key);
}}
onClose={() => {
setSelectedIconPopover(null);
}}
>
{item.component}
</IconPopover>
</EuiFlexItem>
);
}
})}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDescriptionList } from '@elastic/eui';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { APIReturnType } from '../../../../services/rest/createCallApmApi';
type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>;
interface Props {
service: ServiceDetailsReturnType['service'];
}
export function ServiceDetails({ service }: Props) {
if (!service) {
return null;
}
const listItems: EuiDescriptionListProps['listItems'] = [];
if (!!service.versions?.length) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.service.versionLabel',
{
defaultMessage: 'Service version',
}
),
description: (
<ul>
{service.versions.map((version, index) => (
<li key={index}>{version}</li>
))}
</ul>
),
});
}
if (service.runtime) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel',
{
defaultMessage: 'Runtime name & version',
}
),
description: (
<>
{service.runtime.name} {service.runtime.version}
</>
),
});
}
if (service.framework) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel',
{
defaultMessage: 'Framework name',
}
),
description: service.framework,
});
}
if (service.agent) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.service.agentLabel',
{
defaultMessage: 'Agent name & version',
}
),
description: (
<>
{service.agent.name} {service.agent.version}
</>
),
});
}
return <EuiDescriptionList textStyle="reverse" listItems={listItems} />;
}

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SortOptions } from '../../../../../typings/elasticsearch';
import {
AGENT,
CLOUD,
CLOUD_AVAILABILITY_ZONE,
CLOUD_MACHINE_TYPE,
CONTAINER,
HOST,
KUBERNETES,
PROCESSOR_EVENT,
SERVICE,
SERVICE_NAME,
SERVICE_NODE_NAME,
SERVICE_VERSION,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { ContainerType } from '../../../common/service_metadata';
import { rangeFilter } from '../../../common/utils/range_filter';
import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
type ServiceMetadataDetailsRaw = Pick<
TransactionRaw,
'service' | 'agent' | 'host' | 'container' | 'kubernetes' | 'cloud'
>;
interface ServiceMetadataDetails {
service?: {
versions?: string[];
runtime?: {
name: string;
version: string;
};
framework?: string;
agent: {
name: string;
version: string;
};
};
container?: {
os?: string;
isContainerized?: boolean;
totalNumberInstances?: number;
type?: ContainerType;
};
cloud?: {
provider?: string;
availabilityZones?: string[];
machineTypes?: string[];
projectName?: string;
};
}
export async function getServiceMetadataDetails({
serviceName,
setup,
}: {
serviceName: string;
setup: Setup & SetupTimeRange;
}): Promise<ServiceMetadataDetails> {
const { start, end, apmEventClient } = setup;
const filter = [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
{ range: rangeFilter(start, end) },
...setup.esFilter,
];
const should = [
{ exists: { field: CONTAINER } },
{ exists: { field: KUBERNETES } },
{ exists: { field: CLOUD } },
{ exists: { field: HOST } },
{ exists: { field: AGENT } },
];
const params = {
apm: {
events: [ProcessorEvent.transaction],
},
body: {
size: 1,
_source: [SERVICE, AGENT, HOST, CONTAINER, KUBERNETES, CLOUD],
query: { bool: { filter, should } },
aggs: {
serviceVersions: {
terms: {
field: SERVICE_VERSION,
size: 10,
order: { _key: 'desc' } as SortOptions,
},
},
availabilityZones: {
terms: {
field: CLOUD_AVAILABILITY_ZONE,
size: 10,
},
},
machineTypes: {
terms: {
field: CLOUD_MACHINE_TYPE,
size: 10,
},
},
totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } },
},
},
};
const response = await apmEventClient.search(params);
if (response.hits.total.value === 0) {
return {
service: undefined,
container: undefined,
cloud: undefined,
};
}
const { service, agent, host, kubernetes, container, cloud } = response.hits
.hits[0]._source as ServiceMetadataDetailsRaw;
const serviceMetadataDetails = {
versions: response.aggregations?.serviceVersions.buckets.map(
(bucket) => bucket.key as string
),
runtime: service.runtime,
framework: service.framework?.name,
agent,
};
const totalNumberInstances =
response.aggregations?.totalNumberInstances.value;
const containerDetails =
host || container || totalNumberInstances || kubernetes
? {
os: host?.os?.platform,
type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType,
isContainerized: !!container?.id,
totalNumberInstances,
}
: undefined;
const cloudDetails = cloud
? {
provider: cloud.provider,
projectName: cloud.project?.name,
availabilityZones: response.aggregations?.availabilityZones.buckets.map(
(bucket) => bucket.key as string
),
machineTypes: response.aggregations?.machineTypes.buckets.map(
(bucket) => bucket.key as string
),
}
: undefined;
return {
service: serviceMetadataDetails,
container: containerDetails,
cloud: cloudDetails,
};
}

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
AGENT_NAME,
CLOUD_PROVIDER,
CONTAINER_ID,
KUBERNETES,
PROCESSOR_EVENT,
SERVICE_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { ContainerType } from '../../../common/service_metadata';
import { rangeFilter } from '../../../common/utils/range_filter';
import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
type ServiceMetadataIconsRaw = Pick<
TransactionRaw,
'kubernetes' | 'cloud' | 'container' | 'agent'
>;
interface ServiceMetadataIcons {
agentName?: string;
containerType?: ContainerType;
cloudProvider?: string;
}
export async function getServiceMetadataIcons({
serviceName,
setup,
}: {
serviceName: string;
setup: Setup & SetupTimeRange;
}): Promise<ServiceMetadataIcons> {
const { start, end, apmEventClient } = setup;
const filter = [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
{ range: rangeFilter(start, end) },
...setup.esFilter,
];
const params = {
apm: {
events: [ProcessorEvent.transaction],
},
terminateAfter: 1,
body: {
size: 1,
_source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME],
query: { bool: { filter } },
},
};
const response = await apmEventClient.search(params);
if (response.hits.total.value === 0) {
return {
agentName: undefined,
containerType: undefined,
cloudProvider: undefined,
};
}
const { kubernetes, cloud, container, agent } = response.hits.hits[0]
._source as ServiceMetadataIconsRaw;
let containerType: ContainerType;
if (!!kubernetes) {
containerType = 'Kubernetes';
} else if (!!container) {
containerType = 'Docker';
}
return {
agentName: agent?.name,
containerType,
cloudProvider: cloud?.provider,
};
}

View file

@ -24,6 +24,8 @@ import {
serviceErrorGroupsRoute,
serviceThroughputRoute,
serviceDependenciesRoute,
serviceMetadataDetailsRoute,
serviceMetadataIconsRoute,
serviceInstancesRoute,
} from './services';
import {
@ -130,6 +132,8 @@ const createApmApi = () => {
.add(serviceErrorGroupsRoute)
.add(serviceThroughputRoute)
.add(serviceDependenciesRoute)
.add(serviceMetadataDetailsRoute)
.add(serviceMetadataIconsRoute)
.add(serviceInstancesRoute)
// Agent configuration

View file

@ -22,6 +22,8 @@ import { getServiceDependencies } from '../lib/services/get_service_dependencies
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
import { getThroughput } from '../lib/services/get_throughput';
import { getServiceInstances } from '../lib/services/get_service_instances';
import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details';
import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons';
export const servicesRoute = createRoute({
endpoint: 'GET /api/apm/services',
@ -46,6 +48,36 @@ export const servicesRoute = createRoute({
},
});
export const serviceMetadataDetailsRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/metadata/details',
params: t.type({
path: t.type({ serviceName: t.string }),
query: t.intersection([uiFiltersRt, rangeRt]),
}),
options: { tags: ['access:apm'] },
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { serviceName } = context.params.path;
return getServiceMetadataDetails({ serviceName, setup });
},
});
export const serviceMetadataIconsRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons',
params: t.type({
path: t.type({ serviceName: t.string }),
query: t.intersection([uiFiltersRt, rangeRt]),
}),
options: { tags: ['access:apm'] },
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { serviceName } = context.params.path;
return getServiceMetadataIcons({ serviceName, setup });
},
});
export const serviceAgentNameRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/agent_name',
params: t.type({

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Cloud {
availability_zone?: string;
instance?: {
name: string;
id: string;
};
machine?: {
type: string;
};
project?: {
id: string;
name: string;
};
provider?: string;
region?: string;
account?: {
id: string;
name: string;
};
image?: {
id: string;
};
}

View file

@ -5,6 +5,7 @@
*/
import { APMBaseDoc } from './apm_base_doc';
import { Cloud } from './fields/cloud';
import { Container } from './fields/container';
import { Host } from './fields/host';
import { Http } from './fields/http';
@ -64,4 +65,5 @@ export interface TransactionRaw extends APMBaseDoc {
url?: Url;
user?: User;
user_agent?: UserAgent;
cloud?: Cloud;
}

View file

@ -5014,7 +5014,6 @@
"xpack.apm.servicesTable.7xUpgradeServerMessage": "バージョン7.xより前からのアップグレードですかまた、\n APMサーバーインスタンスを7.0以降にアップグレードしていることも確認してください。",
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均応答時間",
"xpack.apm.servicesTable.environmentColumnLabel": "環境",
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 個の環境} other {# 個の環境}}",
"xpack.apm.servicesTable.healthColumnLabel": "ヘルス",
"xpack.apm.servicesTable.nameColumnLabel": "名前",
"xpack.apm.servicesTable.noServicesLabel": "APM サービスがインストールされていないようです。追加しましょう!",

View file

@ -5018,7 +5018,6 @@
"xpack.apm.servicesTable.7xUpgradeServerMessage": "从 7.x 之前的版本升级?另外,确保您已将\n APM Server 实例升级到至少 7.0。",
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均响应时间",
"xpack.apm.servicesTable.environmentColumnLabel": "环境",
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 个环境} other {# 个环境}}",
"xpack.apm.servicesTable.healthColumnLabel": "运行状况",
"xpack.apm.servicesTable.nameColumnLabel": "名称",
"xpack.apm.servicesTable.noServicesLabel": "似乎您没有安装任何 APM 服务。让我们添加一些!",

View file

@ -170,6 +170,20 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
expectForbidden: expect403,
expectResponse: expect200,
},
{
req: {
url: `/api/apm/services/foo/metadata/details?start=${start}&end=${end}&uiFilters=%7B%7D`,
},
expectForbidden: expect403,
expectResponse: expect200,
},
{
req: {
url: `/api/apm/services/foo/metadata/icons?start=${start}&end=${end}&uiFilters=%7B%7D`,
},
expectForbidden: expect403,
expectResponse: expect200,
},
];
const elasticsearchPrivileges = {

View file

@ -25,6 +25,8 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont
loadTestFile(require.resolve('./services/throughput'));
loadTestFile(require.resolve('./services/top_services'));
loadTestFile(require.resolve('./services/transaction_types'));
loadTestFile(require.resolve('./services/service_details'));
loadTestFile(require.resolve('./services/service_icons'));
});
describe('Service overview', function () {

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import url from 'url';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import archives from '../../../common/archives_metadata';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const { start, end } = archives[archiveName];
describe('Service details', () => {
describe('when data is not loaded ', () => {
it('handles the empty state', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/metadata/details`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expect(response.body).to.eql({});
});
});
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
it('returns java service details', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/metadata/details`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"container": Object {
"isContainerized": true,
"os": "Linux",
"totalNumberInstances": 1,
"type": "Kubernetes",
},
"service": Object {
"agent": Object {
"ephemeral_id": "d27b2271-06b4-48c8-a02a-cfd963c0b4d0",
"name": "java",
"version": "1.19.1-SNAPSHOT.null",
},
"framework": "Servlet API",
"runtime": Object {
"name": "Java",
"version": "11.0.9.1",
},
"versions": Array [
"2020-12-08 03:35:36",
],
},
}
`);
});
it('returns python service details', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-python/metadata/details`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"cloud": Object {
"availabilityZones": Array [
"europe-west1-c",
],
"machineTypes": Array [
"n1-standard-4",
],
"projectName": "elastic-observability",
"provider": "gcp",
},
"container": Object {
"isContainerized": true,
"os": "linux",
"totalNumberInstances": 1,
"type": "Kubernetes",
},
"service": Object {
"agent": Object {
"name": "python",
"version": "5.10.0",
},
"framework": "django",
"runtime": Object {
"name": "CPython",
"version": "3.8.6",
},
"versions": Array [
"2020-12-08 03:35:35",
],
},
}
`);
});
});
});
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import url from 'url';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import archives from '../../../common/archives_metadata';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const { start, end } = archives[archiveName];
describe('Service icons', () => {
describe('when data is not loaded ', () => {
it('handles the empty state', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/metadata/icons`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expect(response.body).to.eql({});
});
});
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
it('returns java service icons', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/metadata/icons`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"agentName": "java",
"containerType": "Kubernetes",
}
`);
});
it('returns python service icons', async () => {
const response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-python/metadata/icons`,
query: {
start,
end,
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"agentName": "python",
"cloudProvider": "gcp",
"containerType": "Kubernetes",
}
`);
});
});
});
}

View file

@ -183,6 +183,11 @@ export interface AggregationOptionsByType {
metrics: { field: string } | MaybeReadonlyArray<{ field: string }>;
sort: SortOptions;
};
avg_bucket: {
buckets_path: string;
gap_policy?: 'skip' | 'insert_zeros';
format?: string;
};
}
type AggregationType = keyof AggregationOptionsByType;
@ -390,6 +395,9 @@ interface AggregationResponsePart<TAggregationOptionsMap extends AggregationOpti
>;
}
];
avg_bucket: {
value: number | null;
};
}
type TopMetricsMap<TFieldName> = TFieldName extends string