[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:
parent
ef99b3345e
commit
6b70784f67
|
@ -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"`;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -119,7 +119,7 @@ export function ServiceOverview({
|
|||
{!isRumAgent && (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup
|
||||
direction={rowDirection}
|
||||
direction="column"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -32,7 +32,7 @@ const ServiceOverviewTableContainerDiv = euiStyled.div<{
|
|||
shouldUseMobileLayout
|
||||
? ''
|
||||
: `
|
||||
height: ${tableHeight}px;
|
||||
min-height: ${tableHeight}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
|
|
@ -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) },
|
||||
});
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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'));
|
||||
|
|
47
x-pack/test/apm_api_integration/tests/service_overview/__snapshots__/instance_details.snap
generated
Normal file
47
x-pack/test/apm_api_integration/tests/service_overview/__snapshots__/instance_details.snap
generated
Normal 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",
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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({});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue