diff --git a/x-pack/plugins/apm/common/service_inventory.ts b/x-pack/plugins/apm/common/service_inventory.ts index b7c8c0ea90a587..022980b6c8193c 100644 --- a/x-pack/plugins/apm/common/service_inventory.ts +++ b/x-pack/plugins/apm/common/service_inventory.ts @@ -18,3 +18,13 @@ export interface ServiceListItem { transactionErrorRate?: number | null; environments?: string[]; } + +export enum ServiceInventoryFieldName { + ServiceName = 'serviceName', + HealthStatus = 'healthStatus', + Environments = 'environments', + TransactionType = 'transactionType', + Throughput = 'throughput', + Latency = 'latency', + TransactionErrorRate = 'transactionErrorRate', +} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 2ed0052547ed6d..082ab1733547e0 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -18,6 +18,10 @@ import { SearchBar } from '../../shared/search_bar'; import { ServiceList } from './service_list'; import { MLCallout, shouldDisplayMlCallout } from '../../shared/ml_callout'; import { joinByKey } from '../../../../common/utils/join_by_key'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { apmServiceInventoryOptimizedSorting } from '../../../../../observability/common'; +import { ServiceInventoryFieldName } from '../../../../common/service_inventory'; +import { orderServiceItems } from './service_list/order_service_items'; const initialData = { requestId: '', @@ -157,10 +161,36 @@ export function ServiceInventory() { /> ); + const mainStatisticsItems = mainStatisticsFetch.data?.items ?? []; + const preloadedServices = sortedAndFilteredServicesFetch.data?.services || []; + + const displayHealthStatus = [ + ...mainStatisticsItems, + ...preloadedServices, + ].some((item) => 'healthStatus' in item); + + const tiebreakerField = useKibana().services.uiSettings?.get( + apmServiceInventoryOptimizedSorting + ) + ? ServiceInventoryFieldName.ServiceName + : ServiceInventoryFieldName.Throughput; + + const initialSortField = displayHealthStatus + ? ServiceInventoryFieldName.HealthStatus + : tiebreakerField; + + const initialSortDirection = + initialSortField === ServiceInventoryFieldName.ServiceName ? 'asc' : 'desc'; + const items = joinByKey( [ - ...(sortedAndFilteredServicesFetch.data?.services ?? []), - ...(mainStatisticsFetch.data?.items ?? []), + // only use preloaded services if tiebreaker field is service.name, + // otherwise ignore them to prevent re-sorting of the table + // once the tiebreaking metric comes in + ...(tiebreakerField === ServiceInventoryFieldName.ServiceName + ? preloadedServices + : []), + ...mainStatisticsItems, ], 'serviceName' ); @@ -187,6 +217,17 @@ export function ServiceInventory() { comparisonFetch.status === FETCH_STATUS.LOADING || comparisonFetch.status === FETCH_STATUS.NOT_INITIATED } + displayHealthStatus={displayHealthStatus} + initialSortField={initialSortField} + initialSortDirection={initialSortDirection} + sortFn={(itemsToSort, sortField, sortDirection) => { + return orderServiceItems({ + items: itemsToSort, + primarySortField: sortField, + sortDirection, + tiebreakerField, + }); + }} comparisonData={comparisonFetch?.data} noItemsMessage={noItemsMessage} /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index cc43be6a790ea8..7bf340bed12a96 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -15,7 +15,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TypeOf } from '@kbn/typed-react-router-config'; -import { orderBy } from 'lodash'; import React, { useMemo } from 'react'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; @@ -45,7 +44,10 @@ import { getTimeSeriesColor, } from '../../../shared/charts/helper/get_timeseries_color'; import { HealthBadge } from './health_badge'; -import { ServiceListItem } from '../../../../../common/service_inventory'; +import { + ServiceInventoryFieldName, + ServiceListItem, +} from '../../../../../common/service_inventory'; type ServicesDetailedStatisticsAPIResponse = APIReturnType<'GET /internal/apm/services/detailed_statistics'>; @@ -54,13 +56,6 @@ function formatString(value?: string | null) { return value || NOT_AVAILABLE_LABEL; } -const SERVICE_HEALTH_STATUS_ORDER = [ - ServiceHealthStatus.unknown, - ServiceHealthStatus.healthy, - ServiceHealthStatus.warning, - ServiceHealthStatus.critical, -]; - export function getServiceColumns({ query, showTransactionTypeColumn, @@ -84,7 +79,7 @@ export function getServiceColumns({ ...(showHealthStatusColumn ? [ { - field: 'healthStatus', + field: ServiceInventoryFieldName.HealthStatus, name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { defaultMessage: 'Health', }), @@ -101,7 +96,7 @@ export function getServiceColumns({ ] : []), { - field: 'serviceName', + field: ServiceInventoryFieldName.ServiceName, name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { defaultMessage: 'Name', }), @@ -123,7 +118,7 @@ export function getServiceColumns({ ...(showWhenSmallOrGreaterThanLarge ? [ { - field: 'environments', + field: ServiceInventoryFieldName.Environments, name: i18n.translate( 'xpack.apm.servicesTable.environmentColumnLabel', { @@ -141,7 +136,7 @@ export function getServiceColumns({ ...(showTransactionTypeColumn && showWhenSmallOrGreaterThanXL ? [ { - field: 'transactionType', + field: ServiceInventoryFieldName.TransactionType, name: i18n.translate( 'xpack.apm.servicesTable.transactionColumnLabel', { defaultMessage: 'Transaction type' } @@ -152,7 +147,7 @@ export function getServiceColumns({ ] : []), { - field: 'latency', + field: ServiceInventoryFieldName.Latency, name: i18n.translate('xpack.apm.servicesTable.latencyAvgColumnLabel', { defaultMessage: 'Latency (avg.)', }), @@ -179,7 +174,7 @@ export function getServiceColumns({ align: RIGHT_ALIGNMENT, }, { - field: 'throughput', + field: ServiceInventoryFieldName.Throughput, name: i18n.translate('xpack.apm.servicesTable.throughputColumnLabel', { defaultMessage: 'Throughput', }), @@ -207,7 +202,7 @@ export function getServiceColumns({ align: RIGHT_ALIGNMENT, }, { - field: 'transactionErrorRate', + field: ServiceInventoryFieldName.TransactionErrorRate, name: i18n.translate('xpack.apm.servicesTable.transactionErrorRate', { defaultMessage: 'Failed transaction rate', }), @@ -246,6 +241,14 @@ interface Props { noItemsMessage?: React.ReactNode; isLoading: boolean; isFailure?: boolean; + displayHealthStatus: boolean; + initialSortField: ServiceInventoryFieldName; + initialSortDirection: 'asc' | 'desc'; + sortFn: ( + sortItems: ServiceListItem[], + sortField: ServiceInventoryFieldName, + sortDirection: 'asc' | 'desc' + ) => ServiceListItem[]; } export function ServiceList({ @@ -255,9 +258,12 @@ export function ServiceList({ comparisonData, isLoading, isFailure, + displayHealthStatus, + initialSortField, + initialSortDirection, + sortFn, }: Props) { const breakpoints = useBreakpoints(); - const displayHealthStatus = items.some((item) => 'healthStatus' in item); const showTransactionTypeColumn = items.some( ({ transactionType }) => @@ -292,9 +298,6 @@ export function ServiceList({ ] ); - const initialSortField = displayHealthStatus ? 'healthStatus' : 'serviceName'; - const initialSortDirection = displayHealthStatus ? 'desc' : 'asc'; - return ( @@ -333,7 +336,7 @@ export function ServiceList({ - isLoading={isLoading} error={isFailure} columns={serviceColumns} @@ -341,41 +344,13 @@ export function ServiceList({ noItemsMessage={noItemsMessage} initialSortField={initialSortField} initialSortDirection={initialSortDirection} - sortFn={(itemsToSort, sortField, sortDirection) => { - // For healthStatus, sort items by healthStatus first, then by name - return sortField === 'healthStatus' - ? orderBy( - itemsToSort, - [ - (item) => { - return item.healthStatus - ? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus) - : -1; - }, - (item) => item.serviceName.toLowerCase(), - ], - [sortDirection, sortDirection === 'asc' ? 'desc' : 'asc'] - ) - : orderBy( - itemsToSort, - (item) => { - switch (sortField) { - // Use `?? -1` here so `undefined` will appear after/before `0`. - // In the table this will make the "N/A" items always at the - // bottom/top. - case 'latency': - return item.latency ?? -1; - case 'throughput': - return item.throughput ?? -1; - case 'transactionErrorRate': - return item.transactionErrorRate ?? -1; - default: - return item[sortField as keyof typeof item]; - } - }, - sortDirection - ); - }} + sortFn={(itemsToSort, sortField, sortDirection) => + sortFn( + itemsToSort, + sortField as ServiceInventoryFieldName, + sortDirection + ) + } /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.test.ts b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.test.ts new file mode 100644 index 00000000000000..5877ee64239b6b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.test.ts @@ -0,0 +1,230 @@ +/* + * 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 { ServiceHealthStatus } from '../../../../../common/service_health_status'; +import { ServiceInventoryFieldName } from '../../../../../common/service_inventory'; +import { orderServiceItems } from './order_service_items'; + +describe('orderServiceItems', () => { + describe('when sorting by health status', () => { + describe('desc', () => { + it('orders from critical to unknown', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.HealthStatus, + sortDirection: 'desc', + tiebreakerField: ServiceInventoryFieldName.Throughput, + items: [ + { + serviceName: 'critical-service', + healthStatus: ServiceHealthStatus.critical, + }, + { + serviceName: 'healthy-service', + healthStatus: ServiceHealthStatus.healthy, + }, + { + serviceName: 'warning-service', + healthStatus: ServiceHealthStatus.warning, + }, + { + serviceName: 'unknown-service', + healthStatus: ServiceHealthStatus.unknown, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + 'critical-service', + 'warning-service', + 'healthy-service', + 'unknown-service', + ]); + }); + + it('sorts by service name ascending as a tie-breaker', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.HealthStatus, + sortDirection: 'desc', + tiebreakerField: ServiceInventoryFieldName.ServiceName, + items: [ + { + serviceName: 'b-critical-service', + healthStatus: ServiceHealthStatus.critical, + }, + { + serviceName: 'a-critical-service', + healthStatus: ServiceHealthStatus.critical, + }, + { + serviceName: 'a-unknown-service', + healthStatus: ServiceHealthStatus.unknown, + }, + { + serviceName: 'b-unknown-service', + healthStatus: ServiceHealthStatus.unknown, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + 'a-critical-service', + 'b-critical-service', + 'a-unknown-service', + 'b-unknown-service', + ]); + }); + + it('sorts by metric descending as a tie-breaker', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.HealthStatus, + sortDirection: 'desc', + tiebreakerField: ServiceInventoryFieldName.Throughput, + items: [ + { + serviceName: 'low-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 1, + }, + { + serviceName: 'high-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 100, + }, + { + serviceName: 'med-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 10, + }, + { + serviceName: 'critical-service', + healthStatus: ServiceHealthStatus.critical, + throughput: 0, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + 'critical-service', + 'high-throughput-service', + 'med-throughput-service', + 'low-throughput-service', + ]); + }); + }); + + describe('asc', () => { + it('orders from unknown to critical', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.HealthStatus, + sortDirection: 'asc', + tiebreakerField: ServiceInventoryFieldName.Throughput, + items: [ + { + serviceName: 'critical-service', + healthStatus: ServiceHealthStatus.critical, + }, + { + serviceName: 'healthy-service', + healthStatus: ServiceHealthStatus.healthy, + }, + { + serviceName: 'warning-service', + healthStatus: ServiceHealthStatus.warning, + }, + { + serviceName: 'unknown-service', + healthStatus: ServiceHealthStatus.unknown, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + 'unknown-service', + 'healthy-service', + 'warning-service', + 'critical-service', + ]); + }); + }); + }); + + describe('when sorting by metric fields', () => { + it('sorts correctly', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.Throughput, + sortDirection: 'desc', + tiebreakerField: ServiceInventoryFieldName.Throughput, + items: [ + { + serviceName: 'low-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 1, + }, + { + serviceName: 'high-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 100, + }, + { + serviceName: 'med-throughput-service', + healthStatus: ServiceHealthStatus.unknown, + throughput: 10, + }, + { + serviceName: 'critical-service', + healthStatus: ServiceHealthStatus.critical, + throughput: 0, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + 'high-throughput-service', + 'med-throughput-service', + 'low-throughput-service', + 'critical-service', + ]); + }); + }); + + describe('when sorting by alphabetical fields', () => { + const sortedItems = orderServiceItems({ + primarySortField: ServiceInventoryFieldName.ServiceName, + sortDirection: 'asc', + tiebreakerField: ServiceInventoryFieldName.ServiceName, + items: [ + { + serviceName: 'd-service', + healthStatus: ServiceHealthStatus.unknown, + }, + { + serviceName: 'a-service', + healthStatus: ServiceHealthStatus.unknown, + }, + { + serviceName: 'b-service', + healthStatus: ServiceHealthStatus.unknown, + }, + { + serviceName: 'c-service', + healthStatus: ServiceHealthStatus.unknown, + }, + { + serviceName: '0-service', + healthStatus: ServiceHealthStatus.unknown, + }, + ], + }); + + expect(sortedItems.map((item) => item.serviceName)).toEqual([ + '0-service', + 'a-service', + 'b-service', + 'c-service', + 'd-service', + ]); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts new file mode 100644 index 00000000000000..1e685d82154139 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { orderBy } from 'lodash'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; +import { + ServiceListItem, + ServiceInventoryFieldName, +} from '../../../../../common/service_inventory'; + +type SortValueGetter = (item: ServiceListItem) => string | number; + +const SERVICE_HEALTH_STATUS_ORDER = [ + ServiceHealthStatus.unknown, + ServiceHealthStatus.healthy, + ServiceHealthStatus.warning, + ServiceHealthStatus.critical, +]; + +const sorts: Record = { + [ServiceInventoryFieldName.HealthStatus]: (item) => + item.healthStatus + ? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus) + : -1, + [ServiceInventoryFieldName.ServiceName]: (item) => + item.serviceName.toLowerCase(), + [ServiceInventoryFieldName.Environments]: (item) => + item.environments?.join(', ').toLowerCase() ?? '', + [ServiceInventoryFieldName.TransactionType]: (item) => + item.transactionType ?? '', + [ServiceInventoryFieldName.Latency]: (item) => item.latency ?? -1, + [ServiceInventoryFieldName.Throughput]: (item) => item.throughput ?? -1, + [ServiceInventoryFieldName.TransactionErrorRate]: (item) => + item.transactionErrorRate ?? -1, +}; + +function reverseSortDirection(sortDirection: 'asc' | 'desc') { + return sortDirection === 'asc' ? 'desc' : 'asc'; +} + +export function orderServiceItems({ + items, + primarySortField, + tiebreakerField, + sortDirection, +}: { + items: ServiceListItem[]; + primarySortField: ServiceInventoryFieldName; + tiebreakerField: ServiceInventoryFieldName; + sortDirection: 'asc' | 'desc'; +}): ServiceListItem[] { + // For healthStatus, sort items by healthStatus first, then by tie-breaker + + const sortFn = sorts[primarySortField as ServiceInventoryFieldName]; + + if (primarySortField === ServiceInventoryFieldName.HealthStatus) { + const tiebreakerSortDirection = + tiebreakerField === ServiceInventoryFieldName.ServiceName + ? reverseSortDirection(sortDirection) + : sortDirection; + + const tiebreakerSortFn = sorts[tiebreakerField]; + + return orderBy( + items, + [sortFn, tiebreakerSortFn], + [sortDirection, tiebreakerSortDirection] + ); + } + + return orderBy(items, sortFn, sortDirection); +} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx index 628ef4617417cf..290f1e6b69b778 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx @@ -11,6 +11,7 @@ import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '../../../../../../../../src/core/public'; import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; +import { ServiceInventoryFieldName } from '../../../../../common/service_inventory'; import type { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { ServiceList } from './'; @@ -59,6 +60,10 @@ export const Example: Story = (args) => { Example.args = { isLoading: false, items, + displayHealthStatus: true, + initialSortField: ServiceInventoryFieldName.HealthStatus, + initialSortDirection: 'desc', + sortFn: (sortItems) => sortItems, }; export const EmptyState: Story = (args) => { @@ -67,6 +72,10 @@ export const EmptyState: Story = (args) => { EmptyState.args = { isLoading: false, items: [], + displayHealthStatus: true, + initialSortField: ServiceInventoryFieldName.HealthStatus, + initialSortDirection: 'desc', + sortFn: (sortItems) => sortItems, }; export const WithHealthWarnings: Story = (args) => { diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 3f31eb5f9140cc..1ca110f40bdbf6 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -15,6 +15,7 @@ export { enableComparisonByDefault, enableInfrastructureView, defaultApmServiceEnvironment, + apmServiceInventoryOptimizedSorting, } from './ui_settings_keys'; export const casesFeatureId = 'observabilityCases'; diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index b1328aec7420b3..54eaa9046d874b 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -11,3 +11,5 @@ export const enableComparisonByDefault = 'observability:enableComparisonByDefaul export const enableInfrastructureView = 'observability:enableInfrastructureView'; export const defaultApmServiceEnvironment = 'observability:apmDefaultServiceEnvironment'; export const enableServiceGroups = 'observability:enableServiceGroups'; +export const apmServiceInventoryOptimizedSorting = + 'observability:apmServiceInventoryOptimizedSorting'; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index db6bc3041fe348..60dcc1384aa225 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -16,6 +16,7 @@ import { enableInfrastructureView, defaultApmServiceEnvironment, enableServiceGroups, + apmServiceInventoryOptimizedSorting, } from '../common/ui_settings_keys'; const technicalPreviewLabel = i18n.translate( @@ -98,4 +99,22 @@ export const uiSettings: Record[${technicalPreviewLabel}]` }, + } + ), + schema: schema.boolean(), + value: false, + requiresPageReload: false, + type: 'boolean', + }, };