[APM] Service overview: Instances table metadata foldout (#96467)

* shows instance details

* shows instance details

* shows instance details

* shows instance details

* shows instance details

* adding api test

* addressing PR comments

* addressing PR comments

* addressing PR comments

* addressing PR comments

* fixing ts issues

* fixing ci

* fixing api tests

* fixing api test

* fixing api test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2021-04-20 18:26:58 -04:00 committed by GitHub
parent ef99b3345e
commit 6b70784f67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1255 additions and 41 deletions

View file

@ -23,8 +23,14 @@ Object {
}
`;
exports[`Error CLOUD_ACCOUNT_ID 1`] = `undefined`;
exports[`Error CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Error CLOUD_INSTANCE_ID 1`] = `undefined`;
exports[`Error CLOUD_INSTANCE_NAME 1`] = `undefined`;
exports[`Error CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`;
@ -258,8 +264,14 @@ Object {
}
`;
exports[`Span CLOUD_ACCOUNT_ID 1`] = `undefined`;
exports[`Span CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Span CLOUD_INSTANCE_ID 1`] = `undefined`;
exports[`Span CLOUD_INSTANCE_NAME 1`] = `undefined`;
exports[`Span CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`;
@ -485,8 +497,14 @@ Object {
}
`;
exports[`Transaction CLOUD_ACCOUNT_ID 1`] = `undefined`;
exports[`Transaction CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`;
exports[`Transaction CLOUD_INSTANCE_ID 1`] = `undefined`;
exports[`Transaction CLOUD_INSTANCE_NAME 1`] = `undefined`;
exports[`Transaction CLOUD_MACHINE_TYPE 1`] = `undefined`;
exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`;

View file

@ -10,6 +10,9 @@ 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 CLOUD_ACCOUNT_ID = 'cloud.account.id';
export const CLOUD_INSTANCE_ID = 'cloud.instance.id';
export const CLOUD_INSTANCE_NAME = 'cloud.instance.name';
export const SERVICE = 'service';
export const SERVICE_NAME = 'service.name';

View file

@ -30,13 +30,13 @@ const cloudIcons: Record<string, string> = {
azure: 'logoAzure',
};
function getCloudIcon(provider?: string) {
export function getCloudIcon(provider?: string) {
if (provider) {
return cloudIcons[provider];
}
}
function getContainerIcon(container?: ContainerType) {
export function getContainerIcon(container?: ContainerType) {
if (!container) {
return;
}

View file

@ -119,7 +119,7 @@ export function ServiceOverview({
{!isRumAgent && (
<EuiFlexItem>
<EuiFlexGroup
direction={rowDirection}
direction="column"
gutterSize="s"
responsive={false}
>

View file

@ -5,11 +5,16 @@
* 2.0.
*/
import { EuiBasicTableColumn } from '@elastic/eui';
import {
EuiBasicTableColumn,
EuiButtonIcon,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import React, { ReactNode } from 'react';
import { ActionMenu } from '../../../../../../observability/public';
import { isJavaAgentName } from '../../../../../common/agent_name';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import {
getServiceNodeName,
SERVICE_NODE_NAME_MISSING,
@ -26,6 +31,7 @@ import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink
import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { getLatencyColumnLabel } from '../get_latency_column_label';
import { InstanceActionsMenu } from './instance_actions_menu';
import { MainStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table';
type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>;
@ -36,12 +42,20 @@ export function getColumns({
latencyAggregationType,
detailedStatsData,
comparisonEnabled,
toggleRowDetails,
itemIdToExpandedRowMap,
toggleRowActionMenu,
itemIdToOpenActionMenuRowMap,
}: {
serviceName: string;
agentName?: string;
latencyAggregationType?: LatencyAggregationType;
detailedStatsData?: ServiceInstanceDetailedStatistics;
comparisonEnabled?: boolean;
toggleRowDetails: (selectedServiceNodeName: string) => void;
itemIdToExpandedRowMap: Record<string, ReactNode>;
toggleRowActionMenu: (selectedServiceNodeName: string) => void;
itemIdToOpenActionMenuRowMap: Record<string, boolean>;
}): Array<EuiBasicTableColumn<MainStatsServiceInstanceItem>> {
return [
{
@ -82,7 +96,7 @@ export function getColumns({
sortable: true,
},
{
field: 'latencyValue',
field: 'latency',
name: getLatencyColumnLabel(latencyAggregationType),
width: px(unit * 10),
render: (_, { serviceNodeName, latency }) => {
@ -104,7 +118,7 @@ export function getColumns({
sortable: true,
},
{
field: 'throughputValue',
field: 'throughput',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnThroughput',
{ defaultMessage: 'Throughput' }
@ -130,7 +144,7 @@ export function getColumns({
sortable: true,
},
{
field: 'errorRateValue',
field: 'errorRate',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnErrorRate',
{ defaultMessage: 'Error rate' }
@ -156,7 +170,7 @@ export function getColumns({
sortable: true,
},
{
field: 'cpuUsageValue',
field: 'cpuUsage',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnCpuUsage',
{ defaultMessage: 'CPU usage (avg.)' }
@ -182,7 +196,7 @@ export function getColumns({
sortable: true,
},
{
field: 'memoryUsageValue',
field: 'memoryUsage',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage',
{ defaultMessage: 'Memory usage (avg.)' }
@ -207,5 +221,56 @@ export function getColumns({
},
sortable: true,
},
{
width: '40px',
render: (instanceItem: MainStatsServiceInstanceItem) => {
return (
<ActionMenu
id="instanceActionMenu"
closePopover={() =>
toggleRowActionMenu(instanceItem.serviceNodeName)
}
isOpen={itemIdToOpenActionMenuRowMap[instanceItem.serviceNodeName]}
anchorPosition="leftCenter"
button={
<EuiButtonIcon
iconType="boxesHorizontal"
onClick={() =>
toggleRowActionMenu(instanceItem.serviceNodeName)
}
/>
}
>
<InstanceActionsMenu
serviceName={serviceName}
serviceNodeName={instanceItem.serviceNodeName}
onClose={() => toggleRowActionMenu(instanceItem.serviceNodeName)}
/>
</ActionMenu>
);
},
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (instanceItem: MainStatsServiceInstanceItem) => {
return (
<EuiButtonIcon
onClick={() => toggleRowDetails(instanceItem.serviceNodeName)}
aria-label={
itemIdToExpandedRowMap[instanceItem.serviceNodeName]
? 'Collapse'
: 'Expand'
}
iconType={
itemIdToExpandedRowMap[instanceItem.serviceNodeName]
? 'arrowUp'
: 'arrowDown'
}
/>
);
},
},
];
}

View file

@ -12,7 +12,7 @@ import {
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import React, { ReactNode, useEffect, useState } from 'react';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
@ -26,6 +26,7 @@ import {
} from '../service_overview_instances_chart_and_table';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { getColumns } from './get_columns';
import { InstanceDetails } from './intance_details';
type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>;
@ -65,15 +66,58 @@ export function ServiceOverviewInstancesTable({
urlParams: { latencyAggregationType, comparisonEnabled },
} = useUrlParams();
const [
itemIdToOpenActionMenuRowMap,
setItemIdToOpenActionMenuRowMap,
] = useState<Record<string, boolean>>({});
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<
Record<string, ReactNode>
>({});
useEffect(() => {
// Closes any open rows when fetching new items
setItemIdToExpandedRowMap({});
}, [status]);
const { pageIndex, sort } = tableOptions;
const { direction, field } = sort;
const toggleRowActionMenu = (selectedServiceNodeName: string) => {
const actionMenuRowMapValues = { ...itemIdToOpenActionMenuRowMap };
if (actionMenuRowMapValues[selectedServiceNodeName]) {
delete actionMenuRowMapValues[selectedServiceNodeName];
} else {
actionMenuRowMapValues[selectedServiceNodeName] = true;
}
setItemIdToOpenActionMenuRowMap(actionMenuRowMapValues);
};
const toggleRowDetails = (selectedServiceNodeName: string) => {
const expandedRowMapValues = { ...itemIdToExpandedRowMap };
if (expandedRowMapValues[selectedServiceNodeName]) {
delete expandedRowMapValues[selectedServiceNodeName];
} else {
expandedRowMapValues[selectedServiceNodeName] = (
<InstanceDetails
serviceNodeName={selectedServiceNodeName}
serviceName={serviceName}
/>
);
}
setItemIdToExpandedRowMap(expandedRowMapValues);
};
const columns = getColumns({
agentName,
serviceName,
latencyAggregationType,
detailedStatsData,
comparisonEnabled,
toggleRowDetails,
itemIdToExpandedRowMap,
toggleRowActionMenu,
itemIdToOpenActionMenuRowMap,
});
const pagination = {
@ -106,6 +150,8 @@ export function ServiceOverviewInstancesTable({
pagination={pagination}
sorting={{ sort: { field, direction } }}
onChange={onChangeTableOptions}
itemId="serviceNodeName"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
/>
</ServiceOverviewTableContainer>
</TableFetchWrapper>

View file

@ -0,0 +1,131 @@
/*
* 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 { EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { useHistory } from 'react-router-dom';
import {
ActionMenuDivider,
Section,
SectionLink,
SectionLinks,
SectionSubtitle,
SectionTitle,
} from '../../../../../../../observability/public';
import { isJavaAgentName } from '../../../../../../common/agent_name';
import { SERVICE_NODE_NAME } from '../../../../../../common/elasticsearch_fieldnames';
import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context';
import { useUrlParams } from '../../../../../context/url_params_context/use_url_params';
import { FETCH_STATUS } from '../../../../../hooks/use_fetcher';
import { px } from '../../../../../style/variables';
import { pushNewItemToKueryBar } from '../../../../shared/KueryBar/utils';
import { useMetricOverviewHref } from '../../../../shared/Links/apm/MetricOverviewLink';
import { useServiceNodeMetricOverviewHref } from '../../../../shared/Links/apm/ServiceNodeMetricOverviewLink';
import { useInstanceDetailsFetcher } from '../use_instance_details_fetcher';
import { getMenuSections } from './menu_sections';
interface Props {
serviceName: string;
serviceNodeName: string;
onClose: () => void;
}
const POPOVER_WIDTH = px(305);
export function InstanceActionsMenu({
serviceName,
serviceNodeName,
onClose,
}: Props) {
const { core } = useApmPluginContext();
const { data, status } = useInstanceDetailsFetcher({
serviceName,
serviceNodeName,
});
const serviceNodeMetricOverviewHref = useServiceNodeMetricOverviewHref({
serviceName,
serviceNodeName,
});
const metricOverviewHref = useMetricOverviewHref(serviceName);
const history = useHistory();
const {
urlParams: { kuery },
} = useUrlParams();
if (
status === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.NOT_INITIATED
) {
return (
<div
style={{
width: POPOVER_WIDTH,
display: 'flex',
justifyContent: 'center',
}}
>
<EuiLoadingSpinner />
</div>
);
}
if (!data) {
return null;
}
const handleFilterByInstanceClick = () => {
onClose();
pushNewItemToKueryBar({
kuery,
history,
key: SERVICE_NODE_NAME,
value: serviceNodeName,
});
};
const metricsHref = isJavaAgentName(data.agent?.name)
? serviceNodeMetricOverviewHref
: metricOverviewHref;
const sections = getMenuSections({
instanceDetails: data,
basePath: core.http.basePath,
onFilterByInstanceClick: handleFilterByInstanceClick,
metricsHref,
});
return (
<div style={{ width: POPOVER_WIDTH }}>
{sections.map((section, idx) => {
const isLastSection = idx !== sections.length - 1;
return (
<div key={idx}>
{section.map((item) => (
<Section key={item.key}>
{item.title && <SectionTitle>{item.title}</SectionTitle>}
{item.subtitle && (
<SectionSubtitle>{item.subtitle}</SectionSubtitle>
)}
<SectionLinks>
{item.actions.map((action) => (
<SectionLink
key={action.key}
label={action.label}
href={action.href}
onClick={action.onClick}
color="primary"
/>
))}
</SectionLinks>
</Section>
))}
{isLastSection && <ActionMenuDivider />}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,203 @@
/*
* 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 { IBasePath } from 'kibana/public';
import { isEmpty } from 'lodash';
import moment from 'moment';
import { APIReturnType } from '../../../../../services/rest/createCallApmApi';
import { getInfraHref } from '../../../../shared/Links/InfraLink';
type InstaceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>;
interface Action {
key: string;
label: string;
href?: string;
onClick?: () => void;
condition: boolean;
}
interface Section {
key: string;
title?: string;
subtitle?: string;
actions: Action[];
}
type SectionRecord = Record<string, Section[]>;
function getInfraMetricsQuery(timestamp?: string) {
if (!timestamp) {
return { from: 0, to: 0 };
}
const timeInMilliseconds = new Date(timestamp).getTime();
const fiveMinutes = moment.duration(5, 'minutes').asMilliseconds();
return {
from: timeInMilliseconds - fiveMinutes,
to: timeInMilliseconds + fiveMinutes,
};
}
export function getMenuSections({
instanceDetails,
basePath,
onFilterByInstanceClick,
metricsHref,
}: {
instanceDetails: InstaceDetails;
basePath: IBasePath;
onFilterByInstanceClick: () => void;
metricsHref: string;
}) {
const podId = instanceDetails.kubernetes?.pod?.uid;
const containerId = instanceDetails.container?.id;
const time = instanceDetails['@timestamp']
? new Date(instanceDetails['@timestamp']).valueOf()
: undefined;
const infraMetricsQuery = getInfraMetricsQuery(instanceDetails['@timestamp']);
const podActions: Action[] = [
{
key: 'podLogs',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.podLogs',
{ defaultMessage: 'Pod logs' }
),
href: getInfraHref({
app: 'logs',
basePath,
path: `/link-to/pod-logs/${podId}`,
query: { time },
}),
condition: !!podId,
},
{
key: 'podMetrics',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.podMetrics',
{ defaultMessage: 'Pod metrics' }
),
href: getInfraHref({
app: 'metrics',
basePath,
path: `/link-to/pod-detail/${podId}`,
query: infraMetricsQuery,
}),
condition: !!podId,
},
];
const containerActions: Action[] = [
{
key: 'containerLogs',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.containerLogs',
{ defaultMessage: 'Container logs' }
),
href: getInfraHref({
app: 'logs',
basePath,
path: `/link-to/container-logs/${containerId}`,
query: { time },
}),
condition: !!containerId,
},
{
key: 'containerMetrics',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.containerMetrics',
{ defaultMessage: 'Container metrics' }
),
href: getInfraHref({
app: 'metrics',
basePath,
path: `/link-to/container-detail/${containerId}`,
query: infraMetricsQuery,
}),
condition: !!containerId,
},
];
const apmActions: Action[] = [
{
key: 'filterByInstance',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.filterByInstance',
{
defaultMessage: 'Filter overview by instance',
}
),
onClick: onFilterByInstanceClick,
condition: true,
},
{
key: 'analyzeRuntimeMetric',
label: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.metrics',
{
defaultMessage: 'Metrics',
}
),
href: metricsHref,
condition: true,
},
];
const sectionRecord: SectionRecord = {
observability: [
{
key: 'podDetails',
title: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.pod.title',
{
defaultMessage: 'Pod details',
}
),
subtitle: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.pod.subtitle',
{
defaultMessage:
'View logs and metrics for this pod to get further details.',
}
),
actions: podActions,
},
{
key: 'containerDetails',
title: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.container.title',
{
defaultMessage: 'Container details',
}
),
subtitle: i18n.translate(
'xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle',
{
defaultMessage:
'View logs and metrics for this container to get further details.',
}
),
actions: containerActions,
},
],
apm: [{ key: 'apm', actions: apmActions }],
};
// Filter out actions that shouldnt be shown and sections without any actions.
return Object.values(sectionRecord)
.map((sections) =>
sections
.map((section) => ({
...section,
actions: section.actions.filter((action) => action.condition),
}))
.filter((section) => !isEmpty(section.actions))
)
.filter((sections) => !isEmpty(sections));
}

View file

@ -0,0 +1,144 @@
/*
* 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, EuiLoadingContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import React from 'react';
import { useHistory } from 'react-router-dom';
import {
CLOUD_AVAILABILITY_ZONE,
CLOUD_INSTANCE_ID,
CLOUD_INSTANCE_NAME,
CLOUD_MACHINE_TYPE,
CLOUD_PROVIDER,
CONTAINER_ID,
HOST_NAME,
POD_NAME,
SERVICE_NODE_NAME,
SERVICE_RUNTIME_NAME,
SERVICE_RUNTIME_VERSION,
SERVICE_VERSION,
} from '../../../../../common/elasticsearch_fieldnames';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useTheme } from '../../../../hooks/use_theme';
import { APIReturnType } from '../../../../services/rest/createCallApmApi';
import { pct } from '../../../../style/variables';
import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon';
import { KeyValueFilterList } from '../../../shared/key_value_filter_list';
import { pushNewItemToKueryBar } from '../../../shared/KueryBar/utils';
import {
getCloudIcon,
getContainerIcon,
} from '../../service_details/service_icons';
import { useInstanceDetailsFetcher } from './use_instance_details_fetcher';
type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>;
interface Props {
serviceName: string;
serviceNodeName: string;
}
function toKeyValuePairs(keys: string[], data: ServiceInstanceDetails) {
return keys.map((key) => ({ key, value: get(data, key) }));
}
const serviceDetailsKeys = [
SERVICE_NODE_NAME,
SERVICE_VERSION,
SERVICE_RUNTIME_NAME,
SERVICE_RUNTIME_VERSION,
];
const containerDetailsKeys = [CONTAINER_ID, HOST_NAME, POD_NAME];
const cloudDetailsKeys = [
CLOUD_AVAILABILITY_ZONE,
CLOUD_INSTANCE_ID,
CLOUD_INSTANCE_NAME,
CLOUD_MACHINE_TYPE,
CLOUD_PROVIDER,
];
export function InstanceDetails({ serviceName, serviceNodeName }: Props) {
const theme = useTheme();
const history = useHistory();
const {
urlParams: { kuery },
} = useUrlParams();
const { data, status } = useInstanceDetailsFetcher({
serviceName,
serviceNodeName,
});
if (
status === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.NOT_INITIATED
) {
return (
<div style={{ width: pct(50) }}>
<EuiLoadingContent />
</div>
);
}
if (!data) {
return null;
}
const addKueryBarFilter = ({ key, value }: { key: string; value: any }) => {
pushNewItemToKueryBar({ kuery, history, key, value });
};
const serviceDetailsKeyValuePairs = toKeyValuePairs(serviceDetailsKeys, data);
const containerDetailsKeyValuePairs = toKeyValuePairs(
containerDetailsKeys,
data
);
const cloudDetailsKeyValuePairs = toKeyValuePairs(cloudDetailsKeys, data);
const containerType = data.kubernetes?.pod?.name ? 'Kubernetes' : 'Docker';
return (
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<KeyValueFilterList
initialIsOpen
title={i18n.translate(
'xpack.apm.serviceOverview.instanceTable.details.serviceTitle',
{ defaultMessage: 'Service' }
)}
icon={getAgentIcon(data.agent?.name, theme.darkMode)}
keyValueList={serviceDetailsKeyValuePairs}
onClickFilter={addKueryBarFilter}
/>
</EuiFlexItem>
<EuiFlexItem>
<KeyValueFilterList
title={i18n.translate(
'xpack.apm.serviceOverview.instanceTable.details.containerTitle',
{ defaultMessage: 'Container' }
)}
icon={getContainerIcon(containerType)}
keyValueList={containerDetailsKeyValuePairs}
onClickFilter={addKueryBarFilter}
/>
</EuiFlexItem>
<EuiFlexItem>
<KeyValueFilterList
title={i18n.translate(
'xpack.apm.serviceOverview.instanceTable.details.cloudTitle',
{ defaultMessage: 'Cloud' }
)}
icon={getCloudIcon(data.cloud?.provider)}
keyValueList={cloudDetailsKeyValuePairs}
onClickFilter={addKueryBarFilter}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,52 @@
/*
* 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 { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
export function useInstanceDetailsFetcher({
serviceName,
serviceNodeName,
}: {
serviceName: string;
serviceNodeName: string;
}) {
const {
urlParams: { start, end, kuery, environment },
} = useUrlParams();
const { transactionType } = useApmServiceContext();
const { data, status } = useFetcher(
(callApmApi) => {
if (!start || !end || !transactionType) {
return;
}
return callApmApi({
endpoint:
'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}',
params: {
path: {
serviceName,
serviceNodeName,
},
query: { start, end, transactionType, environment, kuery },
},
});
},
[
serviceName,
serviceNodeName,
start,
end,
transactionType,
environment,
kuery,
]
);
return { data, status };
}

View file

@ -32,7 +32,7 @@ const ServiceOverviewTableContainerDiv = euiStyled.div<{
shouldUseMobileLayout
? ''
: `
height: ${tableHeight}px;
min-height: ${tableHeight}px;
display: flex;
flex-direction: column;

View file

@ -0,0 +1,28 @@
/*
* 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 { History } from 'history';
import { isEmpty } from 'lodash';
import { push } from '../Links/url_helpers';
export function pushNewItemToKueryBar({
kuery,
history,
key,
value,
}: {
kuery?: string;
history: History;
key: string;
value: any;
}) {
const newItem = `${key} :"${value}"`;
const nextKuery = isEmpty(kuery) ? newItem : `${kuery} and ${newItem}`;
push(history, {
query: { kuery: encodeURIComponent(nextKuery) },
});
}

View file

@ -5,40 +5,46 @@
* 2.0.
*/
import { EuiLink } from '@elastic/eui';
import React from 'react';
import { APMLink, APMLinkExtendProps } from './APMLink';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { pickKeys } from '../../../../../common/utils/pick_keys';
import { APMQueryParams } from '../url_helpers';
import { APMLinkExtendProps, useAPMHref } from './APMLink';
interface Props extends APMLinkExtendProps {
serviceName: string;
serviceNodeName: string;
}
function ServiceNodeMetricOverviewLink({
const persistedFilters: Array<keyof APMQueryParams> = [
'host',
'containerId',
'podName',
'serviceVersion',
];
export function useServiceNodeMetricOverviewHref({
serviceName,
serviceNodeName,
}: {
serviceName: string;
serviceNodeName: string;
}) {
return useAPMHref({
path: `/services/${serviceName}/nodes/${encodeURIComponent(
serviceNodeName
)}/metrics`,
persistedFilters,
});
}
export function ServiceNodeMetricOverviewLink({
serviceName,
serviceNodeName,
...rest
}: Props) {
const { urlParams } = useUrlParams();
const persistedFilters = pickKeys(
urlParams,
'host',
'containerId',
'podName',
'serviceVersion'
);
return (
<APMLink
path={`/services/${serviceName}/nodes/${encodeURIComponent(
serviceNodeName
)}/metrics`}
query={persistedFilters}
{...rest}
/>
);
const href = useServiceNodeMetricOverviewHref({
serviceName,
serviceNodeName,
});
return <EuiLink href={href} {...rest} />;
}
export { ServiceNodeMetricOverviewLink };

View file

@ -0,0 +1,147 @@
/*
* 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 {
EuiAccordion,
EuiButtonEmpty,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { px, units } from '../../../style/variables';
interface KeyValue {
key: string;
value: any | undefined;
}
const StyledEuiAccordion = styled(EuiAccordion)`
width: 100%;
.buttonContentContainer .euiIEFlexWrapFix {
width: 100%;
}
`;
const StyledEuiDescriptionList = styled(EuiDescriptionList)`
margin: ${px(units.half)} ${px(units.half)} 0 ${px(units.half)};
.descriptionList__title,
.descriptionList__description {
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
margin-top: 0;
align-items: center;
display: flex;
`;
const ValueContainer = styled.div`
display: flex;
align-items: center;
`;
function removeEmptyValues(items: KeyValue[]) {
return items.filter(({ value }) => value !== undefined);
}
export function KeyValueFilterList({
icon,
title,
keyValueList,
initialIsOpen = false,
onClickFilter,
}: {
title: string;
keyValueList: KeyValue[];
initialIsOpen?: boolean;
icon?: string;
onClickFilter: (filter: { key: string; value: any }) => void;
}) {
if (!keyValueList.length) {
return null;
}
return (
<StyledEuiAccordion
initialIsOpen={initialIsOpen}
id={title}
buttonContent={<AccordionButtonContent icon={icon} title={title} />}
buttonClassName="buttonContentContainer"
>
<StyledEuiDescriptionList type="column">
{removeEmptyValues(keyValueList).map(({ key, value }) => {
return (
<Fragment key={key}>
<EuiDescriptionListTitle
className="descriptionList__title"
style={{ width: '20%' }}
>
<EuiText size="s" style={{ fontWeight: 'bold' }}>
{key}
</EuiText>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="descriptionList__description"
style={{ width: '80%' }}
>
<ValueContainer>
<EuiButtonEmpty
onClick={() => {
onClickFilter({ key, value });
}}
data-test-subj={`filter_by_${key}`}
>
<EuiToolTip
position="top"
content={i18n.translate(
'xpack.apm.keyValueFilterList.actionFilterLabel',
{ defaultMessage: 'Filter by value' }
)}
>
<EuiIcon type="filter" color="black" size="m" />
</EuiToolTip>
</EuiButtonEmpty>
<EuiText size="s">{value}</EuiText>
</ValueContainer>
</EuiDescriptionListDescription>
</Fragment>
);
})}
</StyledEuiDescriptionList>
</StyledEuiAccordion>
);
}
function AccordionButtonContent({
icon,
title,
}: {
icon?: string;
title: string;
}) {
return (
<EuiFlexGroup responsive={false} gutterSize="s">
{icon && (
<EuiFlexItem grow={false}>
<EuiIcon
type={icon}
size="l"
title={title}
data-test-subj="accordion_title_icon"
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText>{title}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -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.
*/
import React from 'react';
import { KeyValueFilterList } from './';
import {
expectTextsInDocument,
renderWithTheme,
} from '../../../utils/testHelpers';
import { fireEvent } from '@testing-library/react';
describe('KeyValueFilterList', () => {
it('hides accordion when key value list is empty', () => {
const { container } = renderWithTheme(
<KeyValueFilterList
title="foo"
keyValueList={[]}
onClickFilter={jest.fn}
/>
);
expect(container).toBeEmptyDOMElement();
});
it('shows list of key value pairs', () => {
const component = renderWithTheme(
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
]}
onClickFilter={jest.fn}
/>
);
expectTextsInDocument(component, [
'title',
'foo',
'foo value',
'bar',
'bar value',
]);
});
it('shows icon and title on accordion', () => {
const component = renderWithTheme(
<KeyValueFilterList
title="title"
icon="alert"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
]}
onClickFilter={jest.fn}
/>
);
expect(component.getByTestId('accordion_title_icon')).toBeInTheDocument();
expectTextsInDocument(component, ['title']);
});
it('hides icon and only shows title on accordion', () => {
const component = renderWithTheme(
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
]}
onClickFilter={jest.fn}
/>
);
expect(component.queryAllByTestId('accordion_title_icon')).toEqual([]);
expectTextsInDocument(component, ['title']);
});
it('returns selected key value when the filter button is clicked', () => {
const mockFilter = jest.fn();
const component = renderWithTheme(
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
]}
onClickFilter={mockFilter}
/>
);
fireEvent.click(component.getByTestId('filter_by_foo'));
expect(mockFilter).toHaveBeenCalledWith({ key: 'foo', value: 'foo value' });
});
});

View file

@ -0,0 +1,84 @@
/*
* 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 {
SERVICE_NAME,
SERVICE_NODE_NAME,
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import { environmentQuery, kqlQuery, rangeQuery } from '../../utils/queries';
import { withApmSpan } from '../../utils/with_apm_span';
import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
export interface KeyValue {
key: string;
value: any | undefined;
}
export async function getServiceInstanceMetadataDetails({
serviceName,
serviceNodeName,
setup,
searchAggregatedTransactions,
transactionType,
environment,
kuery,
}: {
serviceName: string;
serviceNodeName: string;
setup: Setup & SetupTimeRange;
searchAggregatedTransactions: boolean;
transactionType: string;
environment?: string;
kuery?: string;
}) {
return withApmSpan('get_service_instance_metadata_details', async () => {
const { start, end, apmEventClient } = setup;
const filter = [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [SERVICE_NODE_NAME]: serviceNodeName } },
{ term: { [TRANSACTION_TYPE]: transactionType } },
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
];
const response = await apmEventClient.search({
apm: {
events: [
getProcessorEventForAggregatedTransactions(
searchAggregatedTransactions
),
],
},
body: {
terminate_after: 1,
size: 1,
query: { bool: { filter } },
},
});
const sample = response.hits.hits[0]?._source;
if (!sample) {
return {};
}
const { agent, service, container, kubernetes, host, cloud } = sample;
return {
'@timestamp': sample['@timestamp'],
agent,
service,
container,
kubernetes,
host,
cloud,
};
});
}

View file

@ -18,6 +18,7 @@ import { getServices } from '../lib/services/get_services';
import { getServiceAgentName } from '../lib/services/get_service_agent_name';
import { getServiceAlerts } from '../lib/services/get_service_alerts';
import { getServiceDependencies } from '../lib/services/get_service_dependencies';
import { getServiceInstanceMetadataDetails } from '../lib/services/get_service_instance_metadata_details';
import { getServiceErrorGroupPeriods } from '../lib/services/get_service_error_groups/get_service_error_group_detailed_statistics';
import { getServiceErrorGroupMainStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_main_statistics';
import { getServiceInstancesDetailedStatisticsPeriods } from '../lib/services/get_service_instances/detailed_statistics';
@ -551,7 +552,44 @@ const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({
},
});
const serviceDependenciesRoute = createApmServerRoute({
export const serviceInstancesMetadataDetails = createApmServerRoute({
endpoint:
'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}',
params: t.type({
path: t.type({
serviceName: t.string,
serviceNodeName: t.string,
}),
query: t.intersection([
t.type({ transactionType: t.string }),
environmentRt,
kueryRt,
rangeRt,
]),
}),
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const { serviceName, serviceNodeName } = resources.params.path;
const { transactionType, environment, kuery } = resources.params.query;
const searchAggregatedTransactions = await getSearchAggregatedTransactions(
setup
);
return await getServiceInstanceMetadataDetails({
searchAggregatedTransactions,
setup,
serviceName,
serviceNodeName,
transactionType,
environment,
kuery,
});
},
});
export const serviceDependenciesRoute = createApmServerRoute({
endpoint: 'GET /api/apm/services/{serviceName}/dependencies',
params: t.type({
path: t.type({
@ -724,6 +762,7 @@ export const serviceRouteRepository = createApmServerRouteRepository()
.add(serviceAnnotationsCreateRoute)
.add(serviceErrorGroupsMainStatisticsRoute)
.add(serviceErrorGroupsDetailedStatisticsRoute)
.add(serviceInstancesMetadataDetails)
.add(serviceThroughputRoute)
.add(serviceInstancesMainStatisticsRoute)
.add(serviceInstancesDetailedStatisticsRoute)

View file

@ -6,14 +6,22 @@
*/
import { APMBaseDoc } from './apm_base_doc';
import { Cloud } from './fields/cloud';
import { Container } from './fields/container';
import { Host } from './fields/host';
import { Kubernetes } from './fields/kubernetes';
import { Service } from './fields/service';
type BaseMetric = APMBaseDoc & {
processor: {
name: 'metric';
event: 'metric';
};
cloud?: Cloud;
container?: Container;
kubernetes?: Kubernetes;
service?: Service;
host?: Host;
};
type BaseBreakdownMetric = BaseMetric & {
@ -86,8 +94,6 @@ type TransactionDurationMetric = BaseMetric & {
environment?: string;
version?: string;
};
container?: Container;
kubernetes?: Kubernetes;
};
export type SpanDestinationMetric = BaseMetric & {

View file

@ -73,6 +73,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte
loadTestFile(require.resolve('./service_overview/instances_detailed_statistics'));
});
describe('service_overview/instance_details', function () {
loadTestFile(require.resolve('./service_overview/instance_details'));
});
// Services
describe('services/agent_name', function () {
loadTestFile(require.resolve('./services/agent_name'));

View file

@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`APM API tests basic apm_8.0.0 Instance details when data is loaded fetch instance details return the correct data 1`] = `
Object {
"@timestamp": "2020-12-08T13:59:01.971Z",
"agent": Object {
"ephemeral_id": "d27b2271-06b4-48c8-a02a-cfd963c0b4d0",
"name": "java",
"version": "1.19.1-SNAPSHOT.null",
},
"container": Object {
"id": "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c",
},
"host": Object {
"architecture": "amd64",
"ip": "10.8.4.45",
"os": Object {
"platform": "Linux",
},
},
"kubernetes": Object {
"pod": Object {
"name": "opbeans-java-6bdd78cb5c-k2qz6",
"uid": "805e875d-1fda-42c0-bb54-23eb6faf54ab",
},
},
"service": Object {
"environment": "production",
"framework": Object {
"name": "Servlet API",
},
"language": Object {
"name": "Java",
"version": "11.0.9.1",
},
"name": "opbeans-java",
"node": Object {
"name": "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c",
},
"runtime": Object {
"name": "Java",
"version": "11.0.9.1",
},
"version": "2020-12-08 03:35:36",
},
}
`;

View file

@ -0,0 +1,101 @@
/*
* 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 url from 'url';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import archives from '../../common/fixtures/es_archiver/archives_metadata';
import { registry } from '../../common/registry';
import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi';
type ServiceOverviewInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>;
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const archiveName = 'apm_8.0.0';
const { start, end } = archives[archiveName];
registry.when(
'Instance details when data is not loaded',
{ config: 'basic', archives: [] },
() => {
describe('when data is not loaded', () => {
it('handles empty state', async () => {
const response = await supertest.get(
url.format({
pathname: '/api/apm/services/opbeans-java/service_overview_instances/details/foo',
query: {
start,
end,
transactionType: 'request',
},
})
);
expect(response.status).to.be(200);
expect(response.body).to.eql({});
});
});
}
);
registry.when(
'Instance details when data is loaded',
{ config: 'basic', archives: [archiveName] },
() => {
describe('fetch instance details', () => {
let response: {
status: number;
body: ServiceOverviewInstanceDetails;
};
before(async () => {
response = await supertest.get(
url.format({
pathname:
'/api/apm/services/opbeans-java/service_overview_instances/details/02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c',
query: {
start,
end,
transactionType: 'request',
},
})
);
});
it('returns the instance details', () => {
expect(response.body).to.not.eql({});
});
it('return the correct data', () => {
expectSnapshot(response.body).toMatch();
});
});
}
);
registry.when(
'Instance details when data is loaded but details not found',
{ config: 'basic', archives: [archiveName] },
() => {
it('handles empty state when instance id not found', async () => {
const response = await supertest.get(
url.format({
pathname: '/api/apm/services/opbeans-java/service_overview_instances/details/foo',
query: {
start,
end,
transactionType: 'request',
},
})
);
expect(response.status).to.be(200);
expect(response.body).to.eql({});
});
}
);
}