From 8145cb7c6f483c3a8aa561b492fa098e3ce52027 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Mon, 4 Nov 2024 17:01:05 +0100 Subject: [PATCH] [ECO][Inventory] Redirect ECS k8s entities to dashboards (#197222) closes [#196142](https://github.com/elastic/kibana/issues/196142) ## Summary Links kubernetes ECS entities to their corresponding dashboards > [!IMPORTANT] > ECS `replicaset` doesn't have a dedicated dashboard. `container` will be handled in a separate ticket > Semconv won't link to any dashboard/page image ![redirect](https://github.com/user-attachments/assets/77d5d2e1-7ec4-40cd-b7d8-419e07e6b760) ### How to test - While https://github.com/elastic/kibana/pull/196916 is not merged, change `ENTITIES_LATEST_ALIAS` constant to `'.entities.v1.latest*'` - Start a local kibana and es instances - Run ` node scripts/synthtrace k8s_entities.ts --live --clean ` - Run `PUT kbn:/internal/entities/managed/enablement` on the devtools - Install the kubernetes integration package to have the dashboards installed. - Navigate to `Inventory` and click through the k8s entities --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../src/lib/entities/kubernetes/index.ts | 6 +- .../esql_result_to_plain_objects.test.ts | 66 +++++++ .../es/utils/esql_result_to_plain_objects.ts | 12 +- .../public/lib/entity_client.test.ts | 139 ++++++++++++++ .../public/lib/entity_client.ts | 40 ++++ .../apm/common/entities/types.ts | 12 -- .../apm/common/es_fields/entities.ts | 12 -- .../asset_details/hooks/use_entity_summary.ts | 4 +- .../tabs/processes/processes.tsx | 4 +- .../infra/server/routes/entities/index.ts | 6 +- .../inventory/common/entities.ts | 7 +- ...parse_identity_field_values_to_kql.test.ts | 91 --------- .../parse_identity_field_values_to_kql.ts | 34 ---- .../common/utils/unflatten_entity.ts | 13 ++ .../alerts_badge/alerts_badge.test.tsx | 44 +++-- .../components/alerts_badge/alerts_badge.tsx | 7 +- .../entity_name/entity_name.test.tsx | 175 +++++------------- .../entities_grid/entity_name/index.tsx | 77 +++----- .../public/components/entity_icon/index.tsx | 11 +- .../hooks/use_detail_view_redirect.test.ts | 170 +++++++++++++++++ .../public/hooks/use_detail_view_redirect.ts | 114 ++++++++++++ .../entities/get_identify_fields.test.ts | 2 +- .../server/routes/has_data/get_has_data.ts | 1 - .../inventory/tsconfig.json | 3 + .../common/entity/entity_types.ts | 26 ++- .../common/entity/index.ts | 2 +- .../observability_shared/common/index.ts | 3 +- .../locators/apm/service_overview_locator.ts | 3 +- 28 files changed, 715 insertions(+), 369 deletions(-) create mode 100644 x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts create mode 100644 x-pack/plugins/entity_manager/public/lib/entity_client.test.ts delete mode 100644 x-pack/plugins/observability_solution/apm/common/entities/types.ts delete mode 100644 x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts delete mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts delete mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts create mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.ts diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts index 36d7f8caf9601..db95dcf4155bc 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts @@ -55,9 +55,9 @@ export class K8sEntity extends Serializable { super({ ...fields, 'entity.type': entityTypeWithSchema, - 'entity.definitionId': `builtin_${entityTypeWithSchema}`, - 'entity.identityFields': identityFields, - 'entity.displayName': getDisplayName({ identityFields, fields }), + 'entity.definition_id': `builtin_${entityTypeWithSchema}`, + 'entity.identity_fields': identityFields, + 'entity.display_name': getDisplayName({ identityFields, fields }), }); } } diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts new file mode 100644 index 0000000000000..4557d0ba0bdd5 --- /dev/null +++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts @@ -0,0 +1,66 @@ +/* + * 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 type { ESQLSearchResponse } from '@kbn/es-types'; +import { esqlResultToPlainObjects } from './esql_result_to_plain_objects'; + +describe('esqlResultToPlainObjects', () => { + it('should return an empty array for an empty result', () => { + const result: ESQLSearchResponse = { + columns: [], + values: [], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([]); + }); + + it('should return plain objects', () => { + const result: ESQLSearchResponse = { + columns: [{ name: 'name', type: 'keyword' }], + values: [['Foo Bar']], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([{ name: 'Foo Bar' }]); + }); + + it('should return columns without "text" or "keyword" in their names', () => { + const result: ESQLSearchResponse = { + columns: [ + { name: 'name.text', type: 'text' }, + { name: 'age', type: 'keyword' }, + ], + values: [ + ['Foo Bar', 30], + ['Foo Qux', 25], + ], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([ + { name: 'Foo Bar', age: 30 }, + { name: 'Foo Qux', age: 25 }, + ]); + }); + + it('should handle mixed columns correctly', () => { + const result: ESQLSearchResponse = { + columns: [ + { name: 'name', type: 'text' }, + { name: 'name.text', type: 'text' }, + { name: 'age', type: 'keyword' }, + ], + values: [ + ['Foo Bar', 'Foo Bar', 30], + ['Foo Qux', 'Foo Qux', 25], + ], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([ + { name: 'Foo Bar', age: 30 }, + { name: 'Foo Qux', age: 25 }, + ]); + }); +}); diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts index ad48bcb311b25..96049f75ef156 100644 --- a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts +++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts @@ -13,7 +13,17 @@ export function esqlResultToPlainObjects>( return result.values.map((row) => { return row.reduce>((acc, value, index) => { const column = result.columns[index]; - acc[column.name] = value; + + if (!column) { + return acc; + } + + // Removes the type suffix from the column name + const name = column.name.replace(/\.(text|keyword)$/, ''); + if (!acc[name]) { + acc[name] = value; + } + return acc; }, {}); }) as T[]; diff --git a/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts b/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts new file mode 100644 index 0000000000000..dbaf1205cdf98 --- /dev/null +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EntityClient, EnitityInstance } from './entity_client'; +import { coreMock } from '@kbn/core/public/mocks'; + +const commonEntityFields: EnitityInstance = { + entity: { + last_seen_timestamp: '2023-10-09T00:00:00Z', + id: '1', + display_name: 'entity_name', + definition_id: 'entity_definition_id', + } as EnitityInstance['entity'], +}; + +describe('EntityClient', () => { + let entityClient: EntityClient; + + beforeEach(() => { + entityClient = new EntityClient(coreMock.createStart()); + }); + + describe('asKqlFilter', () => { + it('should return the kql filter', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('service.name: my-service'); + }); + + it('should return the kql filter when indentity_fields is composed by multiple fields', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + environment: 'staging', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('(service.name: my-service AND service.environment: staging)'); + }); + + it('should ignore fields that are not present in the entity', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['host.name', 'foo.bar'], + }, + host: { + name: 'my-host', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('host.name: my-host'); + }); + }); + + describe('getIdentityFieldsValue', () => { + it('should return identity fields values', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + }, + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + 'service.name': 'my-service', + }); + }); + + it('should return identity fields values when indentity_fields is composed by multiple fields', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + environment: 'staging', + }, + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + 'service.name': 'my-service', + 'service.environment': 'staging', + }); + }); + + it('should return identity fields when field is in the root', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['name'], + type: 'service', + }, + name: 'foo', + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + name: 'foo', + }); + }); + + it('should throw an error when identity fields are missing', () => { + const entityLatest: EnitityInstance = { + ...commonEntityFields, + }; + + expect(() => entityClient.getIdentityFieldsValue(entityLatest)).toThrow( + 'Identity fields are missing' + ); + }); + }); +}); diff --git a/x-pack/plugins/entity_manager/public/lib/entity_client.ts b/x-pack/plugins/entity_manager/public/lib/entity_client.ts index dc22a0b991b0d..08794873ba930 100644 --- a/x-pack/plugins/entity_manager/public/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { z } from '@kbn/zod'; import { CoreSetup, CoreStart } from '@kbn/core/public'; import { ClientRequestParamsOf, @@ -12,6 +13,9 @@ import { createRepositoryClient, isHttpFetchError, } from '@kbn/server-route-repository-client'; +import { type KueryNode, nodeTypes, toKqlExpression } from '@kbn/es-query'; +import { entityLatestSchema } from '@kbn/entities-schema'; +import { castArray } from 'lodash'; import { DisableManagedEntityResponse, EnableManagedEntityResponse, @@ -35,6 +39,8 @@ type CreateEntityDefinitionQuery = QueryParamOf< ClientRequestParamsOf >; +export type EnitityInstance = z.infer; + export class EntityClient { public readonly repositoryClient: EntityManagerRepositoryClient['fetch']; @@ -83,4 +89,38 @@ export class EntityClient { throw err; } } + + asKqlFilter(entityLatest: EnitityInstance) { + const identityFieldsValue = this.getIdentityFieldsValue(entityLatest); + + const nodes: KueryNode[] = Object.entries(identityFieldsValue).map(([identityField, value]) => { + return nodeTypes.function.buildNode('is', identityField, value); + }); + + if (nodes.length === 0) return ''; + + const kqlExpression = nodes.length > 1 ? nodeTypes.function.buildNode('and', nodes) : nodes[0]; + + return toKqlExpression(kqlExpression); + } + + getIdentityFieldsValue(entityLatest: EnitityInstance) { + const { identity_fields: identityFields } = entityLatest.entity; + + if (!identityFields) { + throw new Error('Identity fields are missing'); + } + + return castArray(identityFields).reduce((acc, field) => { + const value = field.split('.').reduce((obj: any, part: string) => { + return obj && typeof obj === 'object' ? (obj as Record)[part] : undefined; + }, entityLatest); + + if (value) { + acc[field] = value; + } + + return acc; + }, {} as Record); + } } diff --git a/x-pack/plugins/observability_solution/apm/common/entities/types.ts b/x-pack/plugins/observability_solution/apm/common/entities/types.ts deleted file mode 100644 index 9775b1e32eae6..0000000000000 --- a/x-pack/plugins/observability_solution/apm/common/entities/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export enum EntityDataStreamType { - METRICS = 'metrics', - TRACES = 'traces', - LOGS = 'logs', -} diff --git a/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts b/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts deleted file mode 100644 index 28e4a3ec79165..0000000000000 --- a/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const ENTITY_METRICS_LATENCY = 'entity.metrics.latency'; -export const ENTITY_METRICS_LOG_ERROR_RATE = 'entity.metrics.logErrorRate'; -export const ENTITY_METRICS_LOG_RATE = 'entity.metrics.logRate'; -export const ENTITY_METRICS_THROUGHPUT = 'entity.metrics.throughput'; -export const ENTITY_METRICS_FAILED_TRANSACTION_RATE = 'entity.metrics.failedTransactionRate'; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts index 349b8e13ae7ab..e62defaac6d4b 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts @@ -6,10 +6,10 @@ */ import * as z from '@kbn/zod'; -import { EntityDataStreamType, EntityType } from '@kbn/observability-shared-plugin/common'; +import { EntityDataStreamType, ENTITY_TYPES } from '@kbn/observability-shared-plugin/common'; import { useFetcher } from '../../../hooks/use_fetcher'; -const EntityTypeSchema = z.union([z.literal(EntityType.HOST), z.literal(EntityType.CONTAINER)]); +const EntityTypeSchema = z.union([z.literal(ENTITY_TYPES.HOST), z.literal(ENTITY_TYPES.CONTAINER)]); const EntityDataStreamSchema = z.union([ z.literal(EntityDataStreamType.METRICS), z.literal(EntityDataStreamType.LOGS), diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx index 4e8fc1e3badb1..2177cd0509085 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx @@ -22,8 +22,8 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { getFieldByType } from '@kbn/metrics-data-access-plugin/common'; import { decodeOrThrow } from '@kbn/io-ts-utils'; -import { EntityType } from '@kbn/observability-shared-plugin/common'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { ENTITY_TYPES } from '@kbn/observability-shared-plugin/common'; import { useSourceContext } from '../../../../containers/metrics_source'; import { isPending, useFetcher } from '../../../../hooks/use_fetcher'; import { parseSearchString } from './parse_search_string'; @@ -58,7 +58,7 @@ export const Processes = () => { const { request$ } = useRequestObservable(); const { isActiveTab } = useTabSwitcherContext(); const { dataStreams, status: dataStreamsStatus } = useEntitySummary({ - entityType: EntityType.HOST, + entityType: ENTITY_TYPES.HOST, entityId: asset.name, }); const addMetricsCalloutId: AddMetricsCalloutKey = 'hostProcesses'; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts index cb169f83f171d..1a8707678e8f7 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { METRICS_APP_ID } from '@kbn/deeplinks-observability/constants'; import { entityCentricExperience } from '@kbn/observability-plugin/common'; import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { ENTITY_TYPES } from '@kbn/observability-shared-plugin/common'; import { getInfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; import { InfraBackendLibs } from '../../lib/infra_types'; import { getDataStreamTypes } from './get_data_stream_types'; @@ -22,7 +23,10 @@ export const initEntitiesConfigurationRoutes = (libs: InfraBackendLibs) => { path: '/api/infra/entities/{entityType}/{entityId}/summary', validate: { params: schema.object({ - entityType: schema.oneOf([schema.literal('host'), schema.literal('container')]), + entityType: schema.oneOf([ + schema.literal(ENTITY_TYPES.HOST), + schema.literal(ENTITY_TYPES.CONTAINER), + ]), entityId: schema.string(), }), }, diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index e5bd12d252767..f686490b90bfc 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; +import { z } from '@kbn/zod'; +import { ENTITY_LATEST, entitiesAliasPattern, entityLatestSchema } from '@kbn/entities-schema'; import { ENTITY_DEFINITION_ID, ENTITY_DISPLAY_NAME, @@ -117,3 +118,7 @@ export type EntityGroup = { } & { [key: string]: any; }; + +export type InventoryEntityLatest = z.infer & { + alertsCount?: number; +}; diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts deleted file mode 100644 index 8703e995b4446..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - ENTITY_DEFINITION_ID, - ENTITY_DISPLAY_NAME, - ENTITY_ID, - ENTITY_IDENTITY_FIELDS, - ENTITY_LAST_SEEN, -} from '@kbn/observability-shared-plugin/common'; -import type { Entity } from '../entities'; -import { parseIdentityFieldValuesToKql } from './parse_identity_field_values_to_kql'; - -const commonEntityFields = { - [ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z', - [ENTITY_ID]: '1', - [ENTITY_DISPLAY_NAME]: 'entity_name', - [ENTITY_DEFINITION_ID]: 'entity_definition_id', - alertCount: 3, -}; - -describe('parseIdentityFieldValuesToKql', () => { - it('should return the value when identityFields is a single string', () => { - const entity: Entity = { - 'agent.name': 'node', - [ENTITY_IDENTITY_FIELDS]: 'service.name', - 'service.name': 'my-service', - 'entity.type': 'service', - ...commonEntityFields, - }; - - const result = parseIdentityFieldValuesToKql({ entity }); - expect(result).toEqual('service.name: "my-service"'); - }); - - it('should return values when identityFields is an array of strings', () => { - const entity: Entity = { - 'agent.name': 'node', - [ENTITY_IDENTITY_FIELDS]: ['service.name', 'service.environment'], - 'service.name': 'my-service', - 'entity.type': 'service', - 'service.environment': 'staging', - ...commonEntityFields, - }; - - const result = parseIdentityFieldValuesToKql({ entity }); - expect(result).toEqual('service.name: "my-service" AND service.environment: "staging"'); - }); - - it('should return an empty string if identityFields is empty string', () => { - const entity: Entity = { - 'agent.name': 'node', - [ENTITY_IDENTITY_FIELDS]: '', - 'service.name': 'my-service', - 'entity.type': 'service', - ...commonEntityFields, - }; - - const result = parseIdentityFieldValuesToKql({ entity }); - expect(result).toEqual(''); - }); - it('should return an empty array if identityFields is empty array', () => { - const entity: Entity = { - 'agent.name': 'node', - [ENTITY_IDENTITY_FIELDS]: [], - 'service.name': 'my-service', - 'entity.type': 'service', - ...commonEntityFields, - }; - - const result = parseIdentityFieldValuesToKql({ entity }); - expect(result).toEqual(''); - }); - - it('should ignore fields that are not present in the entity', () => { - const entity: Entity = { - [ENTITY_IDENTITY_FIELDS]: ['host.name', 'foo.bar'], - 'host.name': 'my-host', - 'entity.type': 'host', - 'cloud.provider': null, - ...commonEntityFields, - }; - - const result = parseIdentityFieldValuesToKql({ entity }); - expect(result).toEqual('host.name: "my-host"'); - }); -}); diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts deleted file mode 100644 index 2e3f3dadd4109..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ENTITY_IDENTITY_FIELDS } from '@kbn/observability-shared-plugin/common'; -import { Entity } from '../entities'; - -type Operator = 'AND'; -export function parseIdentityFieldValuesToKql({ - entity, - operator = 'AND', -}: { - entity: Entity; - operator?: Operator; -}) { - const mapping: string[] = []; - - const identityFields = entity[ENTITY_IDENTITY_FIELDS]; - - if (identityFields) { - const fields = [identityFields].flat(); - - fields.forEach((field) => { - if (field in entity) { - mapping.push(`${[field]}: "${entity[field as keyof Entity]}"`); - } - }); - } - - return mapping.join(` ${operator} `); -} diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.ts b/x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.ts new file mode 100644 index 0000000000000..758d185a5753b --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.ts @@ -0,0 +1,13 @@ +/* + * 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 { unflattenObject } from '@kbn/observability-utils/object/unflatten_object'; +import type { Entity, InventoryEntityLatest } from '../entities'; + +export function unflattenEntity(entity: Entity) { + return unflattenObject(entity) as InventoryEntityLatest; +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx index 60124e7813bc4..b5244cb29f7fc 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx @@ -5,22 +5,35 @@ * 2.0. */ import React from 'react'; -import { type KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; import { render, screen } from '@testing-library/react'; import { AlertsBadge } from './alerts_badge'; -import * as useKibana from '../../hooks/use_kibana'; +import { useKibana } from '../../hooks/use_kibana'; import type { Entity } from '../../../common/entities'; +jest.mock('../../hooks/use_kibana'); +const useKibanaMock = useKibana as jest.Mock; + describe('AlertsBadge', () => { - jest.spyOn(useKibana, 'useKibana').mockReturnValue({ - services: { - http: { - basePath: { - prepend: (path: string) => path, + const mockAsKqlFilter = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + useKibanaMock.mockReturnValue({ + services: { + http: { + basePath: { + prepend: (path: string) => path, + }, + }, + entityManager: { + entityClient: { + asKqlFilter: mockAsKqlFilter, + }, }, }, - }, - } as unknown as KibanaReactContextValue); + }); + }); afterAll(() => { jest.clearAllMocks(); @@ -38,9 +51,11 @@ describe('AlertsBadge', () => { 'cloud.provider': null, alertsCount: 1, }; + mockAsKqlFilter.mockReturnValue('host.name: foo'); + render(); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( - '/app/observability/alerts?_a=(kuery:\'host.name: "foo"\',status:active)' + "/app/observability/alerts?_a=(kuery:'host.name: foo',status:active)" ); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('1'); }); @@ -57,9 +72,11 @@ describe('AlertsBadge', () => { 'cloud.provider': null, alertsCount: 5, }; + mockAsKqlFilter.mockReturnValue('service.name: bar'); + render(); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( - '/app/observability/alerts?_a=(kuery:\'service.name: "bar"\',status:active)' + "/app/observability/alerts?_a=(kuery:'service.name: bar',status:active)" ); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('5'); }); @@ -77,9 +94,12 @@ describe('AlertsBadge', () => { 'cloud.provider': null, alertsCount: 2, }; + + mockAsKqlFilter.mockReturnValue('service.name: bar AND service.environment: prod'); + render(); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( - '/app/observability/alerts?_a=(kuery:\'service.name: "bar" AND service.environment: "prod"\',status:active)' + "/app/observability/alerts?_a=(kuery:'service.name: bar AND service.environment: prod',status:active)" ); expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('2'); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx index ba1b992ff62c1..a5845a7b42dcf 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx @@ -8,20 +8,21 @@ import React from 'react'; import rison from '@kbn/rison'; import { EuiBadge, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Entity } from '../../../common/entities'; +import type { Entity } from '../../../common/entities'; +import { unflattenEntity } from '../../../common/utils/unflatten_entity'; import { useKibana } from '../../hooks/use_kibana'; -import { parseIdentityFieldValuesToKql } from '../../../common/utils/parse_identity_field_values_to_kql'; export function AlertsBadge({ entity }: { entity: Entity }) { const { services: { http: { basePath }, + entityManager, }, } = useKibana(); const activeAlertsHref = basePath.prepend( `/app/observability/alerts?_a=${rison.encode({ - kuery: parseIdentityFieldValuesToKql({ entity }), + kuery: entityManager.entityClient.asKqlFilter(unflattenEntity(entity)), status: 'active', })}` ); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx index 2e4f0c319edfc..d5d08ed415a40 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx @@ -5,148 +5,65 @@ * 2.0. */ -import { type KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; -import * as useKibana from '../../../hooks/use_kibana'; -import { EntityName } from '.'; -import type { Entity } from '../../../../common/entities'; -import { render, screen } from '@testing-library/react'; import React from 'react'; -import { ASSET_DETAILS_LOCATOR_ID } from '@kbn/observability-shared-plugin/common/locators/infra/asset_details_locator'; +import { render, screen } from '@testing-library/react'; +import { EntityName } from '.'; +import { useDetailViewRedirect } from '../../../hooks/use_detail_view_redirect'; +import { Entity } from '../../../../common/entities'; +import { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_ID, + ENTITY_IDENTITY_FIELDS, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '@kbn/observability-shared-plugin/common'; + +jest.mock('../../../hooks/use_detail_view_redirect'); + +const useDetailViewRedirectMock = useDetailViewRedirect as jest.Mock; describe('EntityName', () => { - jest.spyOn(useKibana, 'useKibana').mockReturnValue({ - services: { - share: { - url: { - locators: { - get: (locatorId: string) => { - return { - getRedirectUrl: (params: { [key: string]: any }) => { - if (locatorId === ASSET_DETAILS_LOCATOR_ID) { - return `assets_url/${params.assetType}/${params.assetId}`; - } - return `services_url/${params.serviceName}?environment=${params.environment}`; - }, - }; - }, - }, - }, - }, - }, - } as unknown as KibanaReactContextValue); + const mockEntity: Entity = { + [ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z', + [ENTITY_ID]: '1', + [ENTITY_DISPLAY_NAME]: 'entity_name', + [ENTITY_DEFINITION_ID]: 'entity_definition_id', + [ENTITY_IDENTITY_FIELDS]: ['service.name', 'service.environment'], + [ENTITY_TYPE]: 'service', + }; - afterAll(() => { + beforeEach(() => { jest.clearAllMocks(); }); - it('returns host link', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'host', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'host.name', - 'host.name': 'foo', - 'entity.definition_id': 'host', - 'cloud.provider': null, - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'assets_url/host/foo' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); - }); + it('should render the entity name correctly', () => { + useDetailViewRedirectMock.mockReturnValue({ + getEntityRedirectUrl: jest.fn().mockReturnValue(null), + }); - it('returns container link', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'container', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'container.id', - 'container.id': 'foo', - 'entity.definition_id': 'container', - 'cloud.provider': null, - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'assets_url/container/foo' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); - }); + render(); - it('returns service link without environment', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'service.name', - 'service.name': 'foo', - 'entity.definition_id': 'service', - 'agent.name': 'bar', - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'services_url/foo?environment=undefined' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + expect(screen.getByText('entity_name')).toBeInTheDocument(); }); - it('returns service link with environment', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'service.name', - 'service.name': 'foo', - 'entity.definition_id': 'service', - 'agent.name': 'bar', - 'service.environment': 'baz', - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'services_url/foo?environment=baz' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); - }); + it('should a link when getEntityRedirectUrl returns a URL', () => { + useDetailViewRedirectMock.mockReturnValue({ + getEntityRedirectUrl: jest.fn().mockReturnValue('http://foo.bar'), + }); - it('returns service link with first environment when it is an array', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'service.name', - 'service.name': 'foo', - 'entity.definition_id': 'service', - 'agent.name': 'bar', - 'service.environment': ['baz', 'bar', 'foo'], - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'services_url/foo?environment=baz' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + render(); + + expect(screen.getByRole('link')).toHaveAttribute('href', 'http://foo.bar'); }); - it('returns service link identity fields is an array', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': ['service.name', 'service.environment'], - 'service.name': 'foo', - 'entity.definition_id': 'service', - 'agent.name': 'bar', - 'service.environment': 'baz', - }; - render(); - expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( - 'services_url/foo?environment=baz' - ); - expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + it('should not render a link when getEntityRedirectUrl returns null', () => { + useDetailViewRedirectMock.mockReturnValue({ + getEntityRedirectUrl: jest.fn().mockReturnValue(null), + }); + + render(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx index 982a616da8fda..e8db7013f8cb3 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx @@ -6,19 +6,12 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; -import { - ASSET_DETAILS_LOCATOR_ID, - AssetDetailsLocatorParams, - ENTITY_DISPLAY_NAME, - ENTITY_IDENTITY_FIELDS, - ENTITY_TYPE, - SERVICE_ENVIRONMENT, - ServiceOverviewParams, -} from '@kbn/observability-shared-plugin/common'; import React, { useCallback } from 'react'; -import { Entity } from '../../../../common/entities'; +import { ENTITY_DISPLAY_NAME } from '@kbn/observability-shared-plugin/common'; import { useKibana } from '../../../hooks/use_kibana'; +import type { Entity } from '../../../../common/entities'; import { EntityIcon } from '../../entity_icon'; +import { useDetailViewRedirect } from '../../../hooks/use_detail_view_redirect'; interface EntityNameProps { entity: Entity; @@ -26,14 +19,12 @@ interface EntityNameProps { export function EntityName({ entity }: EntityNameProps) { const { - services: { telemetry, share }, + services: { telemetry }, } = useKibana(); - const assetDetailsLocator = - share?.url.locators.get(ASSET_DETAILS_LOCATOR_ID); + const { getEntityRedirectUrl } = useDetailViewRedirect(); - const serviceOverviewLocator = - share?.url.locators.get('serviceOverviewLocator'); + const href = getEntityRedirectUrl(entity); const handleLinkClick = useCallback(() => { telemetry.reportEntityViewClicked({ @@ -42,47 +33,25 @@ export function EntityName({ entity }: EntityNameProps) { }); }, [entity, telemetry]); - const getEntityRedirectUrl = useCallback(() => { - const type = entity[ENTITY_TYPE]; - // For service, host and container type there is only one identity field - const identityField = Array.isArray(entity[ENTITY_IDENTITY_FIELDS]) - ? entity[ENTITY_IDENTITY_FIELDS][0] - : entity[ENTITY_IDENTITY_FIELDS]; - const identityValue = entity[identityField]; - - switch (type) { - case 'host': - case 'container': - return assetDetailsLocator?.getRedirectUrl({ - assetId: identityValue, - assetType: type, - }); - - case 'service': - return serviceOverviewLocator?.getRedirectUrl({ - serviceName: identityValue, - environment: [entity[SERVICE_ENVIRONMENT] || undefined].flat()[0], - }); - } - }, [entity, assetDetailsLocator, serviceOverviewLocator]); + const entityName = ( + + + + + + + {entity[ENTITY_DISPLAY_NAME]} + + + + ); - return ( + return href ? ( // eslint-disable-next-line @elastic/eui/href-or-on-click - - - - - - - - {entity[ENTITY_DISPLAY_NAME]} - - - + + {entityName} + ) : ( + entityName ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entity_icon/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entity_icon/index.tsx index a62f0026ddfa0..48b21779d2e38 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entity_icon/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entity_icon/index.tsx @@ -6,7 +6,12 @@ */ import React from 'react'; -import { AGENT_NAME, CLOUD_PROVIDER, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import { + AGENT_NAME, + CLOUD_PROVIDER, + ENTITY_TYPE, + ENTITY_TYPES, +} from '@kbn/observability-shared-plugin/common'; import { type CloudProvider, CloudProviderIcon, AgentIcon } from '@kbn/custom-icons'; import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; import type { AgentName } from '@kbn/elastic-agent-utils'; @@ -27,7 +32,7 @@ export function EntityIcon({ entity }: EntityIconProps) { const entityType = entity[ENTITY_TYPE]; const defaultIconSize = euiThemeVars.euiSizeL; - if (entityType === 'host' || entityType === 'container') { + if (entityType === ENTITY_TYPES.HOST || entityType === ENTITY_TYPES.CONTAINER) { const cloudProvider = getSingleValue( entity[CLOUD_PROVIDER] as NotNullableCloudProvider | NotNullableCloudProvider[] ); @@ -49,7 +54,7 @@ export function EntityIcon({ entity }: EntityIconProps) { ); } - if (entityType === 'service') { + if (entityType === ENTITY_TYPES.SERVICE) { const agentName = getSingleValue(entity[AGENT_NAME] as AgentName | AgentName[]); return ; } diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.test.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.test.ts new file mode 100644 index 0000000000000..cf4993f871880 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useDetailViewRedirect } from './use_detail_view_redirect'; +import { useKibana } from './use_kibana'; +import { + AGENT_NAME, + CLOUD_PROVIDER, + CONTAINER_ID, + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_ID, + ENTITY_IDENTITY_FIELDS, + ENTITY_LAST_SEEN, + ENTITY_TYPE, + HOST_NAME, + ENTITY_TYPES, + SERVICE_ENVIRONMENT, + SERVICE_NAME, +} from '@kbn/observability-shared-plugin/common'; +import { unflattenEntity } from '../../common/utils/unflatten_entity'; +import type { Entity } from '../../common/entities'; + +jest.mock('./use_kibana'); +jest.mock('../../common/utils/unflatten_entity'); + +const useKibanaMock = useKibana as jest.Mock; +const unflattenEntityMock = unflattenEntity as jest.Mock; + +const commonEntityFields: Partial = { + [ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z', + [ENTITY_ID]: '1', + [ENTITY_DISPLAY_NAME]: 'entity_name', + [ENTITY_DEFINITION_ID]: 'entity_definition_id', +}; + +describe('useDetailViewRedirect', () => { + const mockGetIdentityFieldsValue = jest.fn(); + const mockAsKqlFilter = jest.fn(); + const mockGetRedirectUrl = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + useKibanaMock.mockReturnValue({ + services: { + share: { + url: { + locators: { + get: jest.fn().mockReturnValue({ + getRedirectUrl: mockGetRedirectUrl, + }), + }, + }, + }, + entityManager: { + entityClient: { + getIdentityFieldsValue: mockGetIdentityFieldsValue, + asKqlFilter: mockAsKqlFilter, + }, + }, + }, + }); + + unflattenEntityMock.mockImplementation((entity) => entity); + }); + + it('getEntityRedirectUrl should return the correct URL for host entity', () => { + const entity: Entity = { + ...(commonEntityFields as Entity), + [ENTITY_IDENTITY_FIELDS]: [HOST_NAME], + [ENTITY_TYPE]: 'host', + [HOST_NAME]: 'host-1', + [CLOUD_PROVIDER]: null, + }; + + mockGetIdentityFieldsValue.mockReturnValue({ [HOST_NAME]: 'host-1' }); + mockGetRedirectUrl.mockReturnValue('asset-details-url'); + + const { result } = renderHook(() => useDetailViewRedirect()); + const url = result.current.getEntityRedirectUrl(entity); + + expect(url).toBe('asset-details-url'); + expect(mockGetRedirectUrl).toHaveBeenCalledWith({ assetId: 'host-1', assetType: 'host' }); + }); + + it('getEntityRedirectUrl should return the correct URL for container entity', () => { + const entity: Entity = { + ...(commonEntityFields as Entity), + [ENTITY_IDENTITY_FIELDS]: [CONTAINER_ID], + [ENTITY_TYPE]: 'container', + [CONTAINER_ID]: 'container-1', + [CLOUD_PROVIDER]: null, + }; + + mockGetIdentityFieldsValue.mockReturnValue({ [CONTAINER_ID]: 'container-1' }); + mockGetRedirectUrl.mockReturnValue('asset-details-url'); + + const { result } = renderHook(() => useDetailViewRedirect()); + const url = result.current.getEntityRedirectUrl(entity); + + expect(url).toBe('asset-details-url'); + expect(mockGetRedirectUrl).toHaveBeenCalledWith({ + assetId: 'container-1', + assetType: 'container', + }); + }); + + it('getEntityRedirectUrl should return the correct URL for service entity', () => { + const entity: Entity = { + ...(commonEntityFields as Entity), + [ENTITY_IDENTITY_FIELDS]: [SERVICE_NAME], + [ENTITY_TYPE]: 'service', + [SERVICE_NAME]: 'service-1', + [SERVICE_ENVIRONMENT]: 'prod', + [AGENT_NAME]: 'node', + }; + mockGetIdentityFieldsValue.mockReturnValue({ [SERVICE_NAME]: 'service-1' }); + mockGetRedirectUrl.mockReturnValue('service-overview-url'); + + const { result } = renderHook(() => useDetailViewRedirect()); + const url = result.current.getEntityRedirectUrl(entity); + + expect(url).toBe('service-overview-url'); + expect(mockGetRedirectUrl).toHaveBeenCalledWith({ + serviceName: 'service-1', + environment: 'prod', + }); + }); + + [ + [ENTITY_TYPES.KUBERNETES.CLUSTER.ecs, 'kubernetes-f4dc26db-1b53-4ea2-a78b-1bfab8ea267c'], + [ENTITY_TYPES.KUBERNETES.CLUSTER.semconv, 'kubernetes_otel-cluster-overview'], + [ENTITY_TYPES.KUBERNETES.CRONJOB.ecs, 'kubernetes-0a672d50-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.DAEMONSET.ecs, 'kubernetes-85879010-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.DEPLOYMENT.ecs, 'kubernetes-5be46210-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.JOB.ecs, 'kubernetes-9bf990a0-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.NODE.ecs, 'kubernetes-b945b7b0-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.POD.ecs, 'kubernetes-3d4d9290-bcb1-11ec-b64f-7dd6e8e82013'], + [ENTITY_TYPES.KUBERNETES.STATEFULSET.ecs, 'kubernetes-21694370-bcb2-11ec-b64f-7dd6e8e82013'], + ].forEach(([entityType, dashboardId]) => { + it(`getEntityRedirectUrl should return the correct URL for ${entityType} entity`, () => { + const entity: Entity = { + ...(commonEntityFields as Entity), + [ENTITY_IDENTITY_FIELDS]: ['some.field'], + [ENTITY_TYPE]: entityType, + }; + + mockAsKqlFilter.mockReturnValue('kql-query'); + mockGetRedirectUrl.mockReturnValue('dashboard-url'); + + const { result } = renderHook(() => useDetailViewRedirect()); + const url = result.current.getEntityRedirectUrl(entity); + + expect(url).toBe('dashboard-url'); + expect(mockGetRedirectUrl).toHaveBeenCalledWith({ + dashboardId, + query: { + language: 'kuery', + query: 'kql-query', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.ts new file mode 100644 index 0000000000000..23380dc3704de --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_detail_view_redirect.ts @@ -0,0 +1,114 @@ +/* + * 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 { + ASSET_DETAILS_LOCATOR_ID, + AssetDetailsLocatorParams, + ENTITY_IDENTITY_FIELDS, + ENTITY_TYPE, + ENTITY_TYPES, + SERVICE_ENVIRONMENT, + SERVICE_OVERVIEW_LOCATOR_ID, + ServiceOverviewParams, +} from '@kbn/observability-shared-plugin/common'; +import { useCallback } from 'react'; +import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public'; +import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import { castArray } from 'lodash'; +import type { Entity } from '../../common/entities'; +import { unflattenEntity } from '../../common/utils/unflatten_entity'; +import { useKibana } from './use_kibana'; + +const KUBERNETES_DASHBOARDS_IDS: Record = { + [ENTITY_TYPES.KUBERNETES.CLUSTER.ecs]: 'kubernetes-f4dc26db-1b53-4ea2-a78b-1bfab8ea267c', + [ENTITY_TYPES.KUBERNETES.CLUSTER.semconv]: 'kubernetes_otel-cluster-overview', + [ENTITY_TYPES.KUBERNETES.CRONJOB.ecs]: 'kubernetes-0a672d50-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.DAEMONSET.ecs]: 'kubernetes-85879010-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.DEPLOYMENT.ecs]: 'kubernetes-5be46210-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.JOB.ecs]: 'kubernetes-9bf990a0-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.NODE.ecs]: 'kubernetes-b945b7b0-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.POD.ecs]: 'kubernetes-3d4d9290-bcb1-11ec-b64f-7dd6e8e82013', + [ENTITY_TYPES.KUBERNETES.STATEFULSET.ecs]: 'kubernetes-21694370-bcb2-11ec-b64f-7dd6e8e82013', +}; + +export const useDetailViewRedirect = () => { + const { + services: { share, entityManager }, + } = useKibana(); + + const locators = share.url.locators; + const assetDetailsLocator = locators.get(ASSET_DETAILS_LOCATOR_ID); + const dashboardLocator = locators.get(DASHBOARD_APP_LOCATOR); + const serviceOverviewLocator = locators.get(SERVICE_OVERVIEW_LOCATOR_ID); + + const getSingleIdentityFieldValue = useCallback( + (entity: Entity) => { + const identityFields = castArray(entity[ENTITY_IDENTITY_FIELDS]); + if (identityFields.length > 1) { + throw new Error(`Multiple identity fields are not supported for ${entity[ENTITY_TYPE]}`); + } + + const identityField = identityFields[0]; + return entityManager.entityClient.getIdentityFieldsValue(unflattenEntity(entity))[ + identityField + ]; + }, + [entityManager.entityClient] + ); + + const getDetailViewRedirectUrl = useCallback( + (entity: Entity) => { + const type = entity[ENTITY_TYPE]; + const identityValue = getSingleIdentityFieldValue(entity); + + switch (type) { + case ENTITY_TYPES.HOST: + case ENTITY_TYPES.CONTAINER: + return assetDetailsLocator?.getRedirectUrl({ + assetId: identityValue, + assetType: type, + }); + + case 'service': + return serviceOverviewLocator?.getRedirectUrl({ + serviceName: identityValue, + // service.environemnt is not part of entity.identityFields + // we need to manually get its value + environment: [entity[SERVICE_ENVIRONMENT] || undefined].flat()[0], + }); + + default: + return undefined; + } + }, + [assetDetailsLocator, getSingleIdentityFieldValue, serviceOverviewLocator] + ); + + const getDashboardRedirectUrl = useCallback( + (entity: Entity) => { + const type = entity[ENTITY_TYPE]; + const dashboardId = KUBERNETES_DASHBOARDS_IDS[type]; + + return dashboardId + ? dashboardLocator?.getRedirectUrl({ + dashboardId, + query: { + language: 'kuery', + query: entityManager.entityClient.asKqlFilter(unflattenEntity(entity)), + }, + }) + : undefined; + }, + [dashboardLocator, entityManager.entityClient] + ); + + const getEntityRedirectUrl = useCallback( + (entity: Entity) => getDetailViewRedirectUrl(entity) ?? getDashboardRedirectUrl(entity), + [getDashboardRedirectUrl, getDetailViewRedirectUrl] + ); + + return { getEntityRedirectUrl }; +}; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts index ffd5ba9c6f855..62d77c08fd27a 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts @@ -30,7 +30,7 @@ describe('getIdentityFields', () => { it('should return a Map with unique entity types and their respective identity fields', () => { const serviceEntity: Entity = { 'agent.name': 'node', - 'entity.identity_fields': ['service.name', 'service.environment'], + [ENTITY_IDENTITY_FIELDS]: ['service.name', 'service.environment'], 'service.name': 'my-service', 'entity.type': 'service', ...commonEntityFields, diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts b/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts index 27ba8c0fe46c3..c1e4a82c343b0 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts @@ -26,7 +26,6 @@ export async function getHasData({ }); const totalCount = esqlResultToPlainObjects(esqlResults)?.[0]._count ?? 0; - return { hasData: totalCount > 0 }; } catch (e) { logger.error(e); diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index d27d170b0990e..bd77df478cad1 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -53,5 +53,8 @@ "@kbn/spaces-plugin", "@kbn/cloud-plugin", "@kbn/storybook", + "@kbn/zod", + "@kbn/dashboard-plugin", + "@kbn/deeplinks-analytics" ] } diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts index b905f542d3473..4d8be9efc59c6 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts @@ -5,7 +5,25 @@ * 2.0. */ -export enum EntityType { - HOST = 'host', - CONTAINER = 'container', -} +const createKubernetesEntity = (base: T) => ({ + ecs: `kubernetes_${base}_ecs` as const, + semconv: `kubernetes_${base}_semconv` as const, +}); + +export const ENTITY_TYPES = { + HOST: 'host', + CONTAINER: 'container', + SERVICE: 'service', + KUBERNETES: { + CLUSTER: createKubernetesEntity('cluster'), + CONTAINER: createKubernetesEntity('container'), + CRONJOB: createKubernetesEntity('cron_job'), + DAEMONSET: createKubernetesEntity('daemon_set'), + DEPLOYMENT: createKubernetesEntity('deployment'), + JOB: createKubernetesEntity('job'), + NAMESPACE: createKubernetesEntity('namespace'), + NODE: createKubernetesEntity('node'), + POD: createKubernetesEntity('pod'), + STATEFULSET: createKubernetesEntity('stateful_set'), + }, +} as const; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts index 27bef43d5ff7a..adc07a2931b60 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { EntityType } from './entity_types'; +export { ENTITY_TYPES } from './entity_types'; export { EntityDataStreamType } from './entity_data_stream_types'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index e9be61e8fde34..b4b7731d166b7 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -193,6 +193,7 @@ export type { export { ServiceOverviewLocatorDefinition, + SERVICE_OVERVIEW_LOCATOR_ID, TransactionDetailsByNameLocatorDefinition, ASSET_DETAILS_FLYOUT_LOCATOR_ID, AssetDetailsFlyoutLocatorDefinition, @@ -218,4 +219,4 @@ export { export { COMMON_OBSERVABILITY_GROUPING } from './embeddable_grouping'; -export { EntityType, EntityDataStreamType } from './entity'; +export { ENTITY_TYPES, EntityDataStreamType } from './entity'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts index 2a4e8aac330ec..e216640f31b4f 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts @@ -16,9 +16,10 @@ export interface ServiceOverviewParams extends SerializableRecord { } export type ServiceOverviewLocator = LocatorPublic; +export const SERVICE_OVERVIEW_LOCATOR_ID = 'serviceOverviewLocator'; export class ServiceOverviewLocatorDefinition implements LocatorDefinition { - public readonly id = 'serviceOverviewLocator'; + public readonly id = SERVICE_OVERVIEW_LOCATOR_ID; public readonly getLocation = async ({ rangeFrom,