From c4d3de83162904d3db19e82720b2dd747dcfc5e6 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Tue, 12 Nov 2024 18:47:00 +0100 Subject: [PATCH] [Inventory][ECO] Replace `Entity` with `InventoryEntityLatest` type (#198760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes [#198758](https://github.com/elastic/kibana/issues/198758) ## Summary This PR removes the Entity type used across the Inventory and replaces it with `InventoryEntityLatest`, which provides strong typing for the latest entity object. This change makes the code leverage TypeScript’s intellisense and autocompletion in the editor, making the code easier to work with and more maintainable across the codebase. `InventoryEntityLatest` is the interface that the API returns and what the UI consumes. Note that this is distinct from the index mapping defined by `entityLatestSchema`, creating a separation layer between Elasticsearch and the UI. --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/lib/entities/kubernetes/index.ts | 2 + .../tutorial/replace_template_strings.js | 1 - .../kbn-entities-schema/src/schema/entity.ts | 14 ++- .../array/join_by_key.test.ts | 46 ++++++++++ .../observability_utils/array/join_by_key.ts | 19 +++- .../client/create_observability_es_client.ts | 37 +++++++- .../es/utils/esql_result_to_plain_objects.ts | 33 ++++--- .../public/lib/entity_client.test.ts | 21 +++-- .../public/lib/entity_client.ts | 23 +++-- .../transaction_details.cy.ts | 5 +- .../entities/get_data_stream_types.test.ts | 4 +- .../routes/entities/get_data_stream_types.ts | 12 +-- .../routes/entities/get_latest_entity.ts | 20 ++-- .../.storybook/get_mock_inventory_context.tsx | 9 +- .../inventory/common/entities.ts | 40 +++----- .../common/utils/entity_type_guards.ts | 25 +++++ .../common/utils/unflatten_entity.ts | 13 --- .../inventory/e2e/cypress/e2e/home.cy.ts | 9 +- .../alerts_badge/alerts_badge.test.tsx | 81 ++++++++++------- .../components/alerts_badge/alerts_badge.tsx | 12 ++- .../entities_grid/entities_grid.stories.tsx | 4 +- .../entity_name/entity_name.test.tsx | 26 ++---- .../entities_grid/entity_name/index.tsx | 9 +- .../components/entities_grid/grid_columns.tsx | 19 ++-- .../public/components/entities_grid/index.tsx | 31 +++---- .../entities_grid/mock/entities_mock.ts | 70 +++++++------- .../public/components/entity_icon/index.tsx | 36 +++----- .../components/grouped_inventory/index.tsx | 22 +++-- .../hooks/use_detail_view_redirect.test.ts | 91 ++++++++++--------- .../public/hooks/use_detail_view_redirect.ts | 85 ++++++++--------- .../public/hooks/use_discover_redirect.ts | 16 ++-- .../routes/entities/get_entity_groups.ts | 5 +- .../routes/entities/get_entity_types.ts | 7 +- .../entities/get_identify_fields.test.ts | 74 ++++++++------- .../get_identity_fields_per_entity_type.ts | 12 +-- .../routes/entities/get_latest_entities.ts | 56 +++++++++--- .../entities/get_latest_entities_alerts.ts | 5 +- .../inventory/server/routes/entities/route.ts | 10 +- .../server/routes/has_data/get_has_data.ts | 6 +- .../inventory/tsconfig.json | 1 - 40 files changed, 573 insertions(+), 438 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/entity_type_guards.ts delete mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.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 db95dcf4155bc..6da1decaab9ab 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 @@ -58,6 +58,8 @@ export class K8sEntity extends Serializable { 'entity.definition_id': `builtin_${entityTypeWithSchema}`, 'entity.identity_fields': identityFields, 'entity.display_name': getDisplayName({ identityFields, fields }), + 'entity.definition_version': '1.0.0', + 'entity.schema_version': '1.0', }); } } diff --git a/src/plugins/home/public/application/components/tutorial/replace_template_strings.js b/src/plugins/home/public/application/components/tutorial/replace_template_strings.js index 75da52e9af2b5..09abb7300866a 100644 --- a/src/plugins/home/public/application/components/tutorial/replace_template_strings.js +++ b/src/plugins/home/public/application/components/tutorial/replace_template_strings.js @@ -38,7 +38,6 @@ export function replaceTemplateStrings(text, params = {}) { filebeat: docLinks.links.filebeat.base, metricbeat: docLinks.links.metricbeat.base, heartbeat: docLinks.links.heartbeat.base, - functionbeat: docLinks.links.functionbeat.base, winlogbeat: docLinks.links.winlogbeat.base, auditbeat: docLinks.links.auditbeat.base, }, diff --git a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts index 9ab02e0931d9c..7bfe505face19 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts @@ -11,9 +11,9 @@ import { arrayOfStringsSchema } from './common'; export const entityBaseSchema = z.object({ id: z.string(), type: z.string(), - identity_fields: arrayOfStringsSchema, + identity_fields: z.union([arrayOfStringsSchema, z.string()]), display_name: z.string(), - metrics: z.record(z.string(), z.number()), + metrics: z.optional(z.record(z.string(), z.number())), definition_version: z.string(), schema_version: z.string(), definition_id: z.string(), @@ -24,10 +24,13 @@ export interface MetadataRecord { } const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); + type Literal = z.infer; -type Metadata = Literal | { [key: string]: Metadata } | Metadata[]; +interface Metadata { + [key: string]: Metadata | Literal | Literal[]; +} export const entityMetadataSchema: z.ZodType = z.lazy(() => - z.union([literalSchema, z.array(entityMetadataSchema), z.record(entityMetadataSchema)]) + z.record(z.string(), z.union([literalSchema, z.array(literalSchema), entityMetadataSchema])) ); export const entityLatestSchema = z @@ -39,3 +42,6 @@ export const entityLatestSchema = z ), }) .and(entityMetadataSchema); + +export type EntityInstance = z.infer; +export type EntityMetadata = z.infer; diff --git a/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts b/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts index 8e0fc6ad09479..bb1d5a2e2410e 100644 --- a/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts +++ b/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts @@ -221,4 +221,50 @@ describe('joinByKey', () => { }, }); }); + + it('deeply merges by unflatten keys', () => { + const joined = joinByKey( + [ + { + service: { + name: 'opbeans-node', + metrics: { + cpu: 0.1, + }, + }, + properties: { + foo: 'bar', + }, + }, + { + service: { + environment: 'prod', + metrics: { + memory: 0.5, + }, + }, + properties: { + foo: 'bar', + }, + }, + ], + 'properties.foo' + ); + + expect(joined).toEqual([ + { + service: { + name: 'opbeans-node', + environment: 'prod', + metrics: { + cpu: 0.1, + memory: 0.5, + }, + }, + properties: { + foo: 'bar', + }, + }, + ]); + }); }); diff --git a/x-pack/packages/observability/observability_utils/array/join_by_key.ts b/x-pack/packages/observability/observability_utils/array/join_by_key.ts index 54e8ecdaf409b..93ec4261d04dc 100644 --- a/x-pack/packages/observability/observability_utils/array/join_by_key.ts +++ b/x-pack/packages/observability/observability_utils/array/join_by_key.ts @@ -18,18 +18,29 @@ export type JoinedReturnType< } >; -type ArrayOrSingle = T | T[]; +function getValueByPath(obj: any, path: string): any { + return path.split('.').reduce((acc, keyPart) => { + // Check if acc is a valid object and has the key + return acc && Object.prototype.hasOwnProperty.call(acc, keyPart) ? acc[keyPart] : undefined; + }, obj); +} +type NestedKeys = T extends object + ? { [K in keyof T]: K extends string ? `${K}` | `${K}.${NestedKeys}` : never }[keyof T] + : never; + +type ArrayOrSingle = T | T[]; +type CombinedNestedKeys = (NestedKeys & NestedKeys) | (keyof T & keyof U); export function joinByKey< T extends Record, U extends UnionToIntersection, - V extends ArrayOrSingle + V extends ArrayOrSingle> >(items: T[], key: V): JoinedReturnType; export function joinByKey< T extends Record, U extends UnionToIntersection, - V extends ArrayOrSingle, + V extends ArrayOrSingle>, W extends JoinedReturnType, X extends (a: T, b: T) => ValuesType >(items: T[], key: V, mergeFn: X): W; @@ -45,7 +56,7 @@ export function joinByKey( items.forEach((current) => { // The key of the map is a stable JSON string of the values from given keys. // We need stable JSON string to support plain object values. - const stableKey = stableStringify(keys.map((k) => current[k])); + const stableKey = stableStringify(keys.map((k) => current[k] ?? getValueByPath(current, k))); if (map.has(stableKey)) { const item = map.get(stableKey); diff --git a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts index 0011e0f17c1c0..09013dcd5a506 100644 --- a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts +++ b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts @@ -9,6 +9,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { ESQLSearchResponse, ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; import { withSpan } from '@kbn/apm-utils'; import type { EsqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import { esqlResultToPlainObjects } from '../utils/esql_result_to_plain_objects'; type SearchRequest = ESSearchRequest & { index: string | string[]; @@ -16,6 +17,20 @@ type SearchRequest = ESSearchRequest & { size: number | boolean; }; +type EsqlQueryParameters = EsqlQueryRequest & { parseOutput?: boolean }; +type EsqlOutputParameters = Omit & { + parseOutput?: true; + format?: 'json'; + columnar?: false; +}; + +type EsqlParameters = EsqlOutputParameters | EsqlQueryParameters; + +export type InferEsqlResponseOf< + TOutput = unknown, + TParameters extends EsqlParameters = EsqlParameters +> = TParameters extends EsqlOutputParameters ? TOutput[] : ESQLSearchResponse; + /** * An Elasticsearch Client with a fully typed `search` method and built-in * APM instrumentation. @@ -25,7 +40,14 @@ export interface ObservabilityElasticsearchClient { operationName: string, parameters: TSearchRequest ): Promise>; - esql(operationName: string, parameters: EsqlQueryRequest): Promise; + esql( + operationName: string, + parameters: TQueryParams + ): Promise>; + esql( + operationName: string, + parameters: TQueryParams + ): Promise>; client: ElasticsearchClient; } @@ -40,11 +62,14 @@ export function createObservabilityEsClient({ }): ObservabilityElasticsearchClient { return { client, - esql(operationName: string, parameters: EsqlQueryRequest) { + esql( + operationName: string, + { parseOutput = true, format = 'json', columnar = false, ...parameters }: TSearchRequest + ) { logger.trace(() => `Request (${operationName}):\n${JSON.stringify(parameters, null, 2)}`); return withSpan({ name: operationName, labels: { plugin } }, () => { return client.esql.query( - { ...parameters }, + { ...parameters, format, columnar }, { querystring: { drop_null_columns: true, @@ -54,7 +79,11 @@ export function createObservabilityEsClient({ }) .then((response) => { logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`); - return response as unknown as ESQLSearchResponse; + + const esqlResponse = response as unknown as ESQLSearchResponse; + + const shouldParseOutput = parseOutput && !columnar && format === 'json'; + return shouldParseOutput ? esqlResultToPlainObjects(esqlResponse) : esqlResponse; }) .catch((error) => { throw error; 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 96049f75ef156..717983a2958c5 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 @@ -6,25 +6,28 @@ */ import type { ESQLSearchResponse } from '@kbn/es-types'; +import { unflattenObject } from '../../object/unflatten_object'; -export function esqlResultToPlainObjects>( +export function esqlResultToPlainObjects( result: ESQLSearchResponse -): T[] { +): TDocument[] { return result.values.map((row) => { - return row.reduce>((acc, value, index) => { - const column = result.columns[index]; + return unflattenObject( + row.reduce>((acc, value, index) => { + const column = result.columns[index]; - if (!column) { - return acc; - } + 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; - } + // 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[]; + return acc; + }, {}) + ) as TDocument; + }); } 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 index dbaf1205cdf98..6679140314cb5 100644 --- a/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { EntityClient, EnitityInstance } from './entity_client'; +import { EntityClient } from './entity_client'; import { coreMock } from '@kbn/core/public/mocks'; +import type { EntityInstance } from '@kbn/entities-schema'; -const commonEntityFields: EnitityInstance = { +const commonEntityFields: EntityInstance = { entity: { last_seen_timestamp: '2023-10-09T00:00:00Z', id: '1', display_name: 'entity_name', definition_id: 'entity_definition_id', - } as EnitityInstance['entity'], + } as EntityInstance['entity'], }; describe('EntityClient', () => { @@ -26,7 +27,7 @@ describe('EntityClient', () => { describe('asKqlFilter', () => { it('should return the kql filter', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['service.name', 'service.environment'], @@ -42,7 +43,7 @@ describe('EntityClient', () => { }); it('should return the kql filter when indentity_fields is composed by multiple fields', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['service.name', 'service.environment'], @@ -59,7 +60,7 @@ describe('EntityClient', () => { }); it('should ignore fields that are not present in the entity', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['host.name', 'foo.bar'], @@ -76,7 +77,7 @@ describe('EntityClient', () => { describe('getIdentityFieldsValue', () => { it('should return identity fields values', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['service.name', 'service.environment'], @@ -93,7 +94,7 @@ describe('EntityClient', () => { }); it('should return identity fields values when indentity_fields is composed by multiple fields', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['service.name', 'service.environment'], @@ -112,7 +113,7 @@ describe('EntityClient', () => { }); it('should return identity fields when field is in the root', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { entity: { ...commonEntityFields.entity, identity_fields: ['name'], @@ -127,7 +128,7 @@ describe('EntityClient', () => { }); it('should throw an error when identity fields are missing', () => { - const entityLatest: EnitityInstance = { + const entityLatest: EntityInstance = { ...commonEntityFields, }; 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 08794873ba930..7132dc50330d5 100644 --- a/x-pack/plugins/entity_manager/public/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { z } from '@kbn/zod'; import { CoreSetup, CoreStart } from '@kbn/core/public'; import { ClientRequestParamsOf, @@ -14,7 +13,7 @@ import { isHttpFetchError, } from '@kbn/server-route-repository-client'; import { type KueryNode, nodeTypes, toKqlExpression } from '@kbn/es-query'; -import { entityLatestSchema } from '@kbn/entities-schema'; +import type { EntityInstance, EntityMetadata } from '@kbn/entities-schema'; import { castArray } from 'lodash'; import { DisableManagedEntityResponse, @@ -39,8 +38,6 @@ type CreateEntityDefinitionQuery = QueryParamOf< ClientRequestParamsOf >; -export type EnitityInstance = z.infer; - export class EntityClient { public readonly repositoryClient: EntityManagerRepositoryClient['fetch']; @@ -90,8 +87,12 @@ export class EntityClient { } } - asKqlFilter(entityLatest: EnitityInstance) { - const identityFieldsValue = this.getIdentityFieldsValue(entityLatest); + asKqlFilter( + entityInstance: { + entity: Pick; + } & Required + ) { + const identityFieldsValue = this.getIdentityFieldsValue(entityInstance); const nodes: KueryNode[] = Object.entries(identityFieldsValue).map(([identityField, value]) => { return nodeTypes.function.buildNode('is', identityField, value); @@ -104,8 +105,12 @@ export class EntityClient { return toKqlExpression(kqlExpression); } - getIdentityFieldsValue(entityLatest: EnitityInstance) { - const { identity_fields: identityFields } = entityLatest.entity; + getIdentityFieldsValue( + entityInstance: { + entity: Pick; + } & Required + ) { + const { identity_fields: identityFields } = entityInstance.entity; if (!identityFields) { throw new Error('Identity fields are missing'); @@ -114,7 +119,7 @@ export class EntityClient { 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); + }, entityInstance); if (value) { acc[field] = value; diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts index 730e9c443854e..af23fc8a2ad7e 100644 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts +++ b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts @@ -115,7 +115,10 @@ describe.skip('Transaction details', () => { ); cy.contains('Top 5 errors', { timeout: 30000 }); - cy.getByTestSubj('topErrorsForTransactionTable').contains('a', '[MockError] Foo').click(); + cy.getByTestSubj('topErrorsForTransactionTable') + .should('be.visible') + .contains('a', '[MockError] Foo', { timeout: 10000 }) + .click(); cy.url().should('include', 'opbeans-java/errors'); }); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts index c66416331e4d0..19f7e47e84fce 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts @@ -74,7 +74,7 @@ describe('getDataStreamTypes', () => { it('should return metrics and entity source_data_stream types when entityCentriExperienceEnabled is true and has entity data', async () => { (getHasMetricsData as jest.Mock).mockResolvedValue(true); (getLatestEntity as jest.Mock).mockResolvedValue({ - 'source_data_stream.type': ['logs', 'metrics'], + sourceDataStreamType: ['logs', 'metrics'], }); const params = { @@ -118,7 +118,7 @@ describe('getDataStreamTypes', () => { it('should return entity source_data_stream types when has no metrics', async () => { (getHasMetricsData as jest.Mock).mockResolvedValue(false); (getLatestEntity as jest.Mock).mockResolvedValue({ - 'source_data_stream.type': ['logs', 'traces'], + sourceDataStreamType: ['logs', 'traces'], }); const params = { diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts index 3218ae257f1a2..f9b2d41bbe050 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts @@ -7,11 +7,9 @@ import { type EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; -import { - EntityDataStreamType, - SOURCE_DATA_STREAM_TYPE, -} from '@kbn/observability-shared-plugin/common'; +import { EntityDataStreamType } from '@kbn/observability-shared-plugin/common'; import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { castArray } from 'lodash'; import { type InfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; import { getHasMetricsData } from './get_has_metrics_data'; import { getLatestEntity } from './get_latest_entity'; @@ -45,15 +43,15 @@ export async function getDataStreamTypes({ return Array.from(sourceDataStreams); } - const entity = await getLatestEntity({ + const latestEntity = await getLatestEntity({ inventoryEsClient: obsEsClient, entityId, entityType, entityManagerClient, }); - if (entity?.[SOURCE_DATA_STREAM_TYPE]) { - [entity[SOURCE_DATA_STREAM_TYPE]].flat().forEach((item) => { + if (latestEntity) { + castArray(latestEntity.sourceDataStreamType).forEach((item) => { sourceDataStreams.add(item as EntityDataStreamType); }); } diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts index 7bcce2964fd13..31e778313f939 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts @@ -7,20 +7,16 @@ import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; import { type EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; -import { - ENTITY_TYPE, - SOURCE_DATA_STREAM_TYPE, -} from '@kbn/observability-shared-plugin/common/field_names/elasticsearch'; +import { ENTITY_TYPE, SOURCE_DATA_STREAM_TYPE } from '@kbn/observability-shared-plugin/common'; import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ type: '*', dataset: ENTITY_LATEST, }); -interface Entity { - [SOURCE_DATA_STREAM_TYPE]: string | string[]; +interface EntitySourceResponse { + sourceDataStreamType?: string | string[]; } export async function getLatestEntity({ @@ -33,7 +29,7 @@ export async function getLatestEntity({ entityType: 'host' | 'container'; entityId: string; entityManagerClient: EntityClient; -}): Promise { +}): Promise { const { definitions } = await entityManagerClient.getEntityDefinitions({ builtIn: true, type: entityType, @@ -41,10 +37,12 @@ export async function getLatestEntity({ const hostOrContainerIdentityField = definitions[0]?.identityFields?.[0]?.field; if (hostOrContainerIdentityField === undefined) { - return { [SOURCE_DATA_STREAM_TYPE]: [] }; + return undefined; } - const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { + const response = await inventoryEsClient.esql<{ + source_data_stream?: { type?: string | string[] }; + }>('get_latest_entities', { query: `FROM ${ENTITIES_LATEST_ALIAS} | WHERE ${ENTITY_TYPE} == ? | WHERE ${hostOrContainerIdentityField} == ? @@ -53,5 +51,5 @@ export async function getLatestEntity({ params: [entityType, entityId], }); - return esqlResultToPlainObjects(latestEntitiesEsqlResponse)[0]; + return { sourceDataStreamType: response[0].source_data_stream?.type }; } diff --git a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx index d3d28fe040198..0188ed3143034 100644 --- a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx +++ b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx @@ -24,7 +24,14 @@ export function getMockInventoryContext(): InventoryKibanaContext { return { ...coreStart, - entityManager: {} as unknown as EntityManagerPublicPluginStart, + entityManager: { + entityClient: { + asKqlFilter: jest.fn(), + getIdentityFieldsValue() { + return 'entity_id'; + }, + }, + } as unknown as EntityManagerPublicPluginStart, observabilityShared: {} as unknown as ObservabilitySharedPluginStart, inference: {} as unknown as InferencePublicStart, share: { diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index 3a9684a38254a..65fd8a4ffbd7a 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -4,24 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { z } from '@kbn/zod'; -import { ENTITY_LATEST, entitiesAliasPattern, entityLatestSchema } from '@kbn/entities-schema'; -import { - ENTITY_DEFINITION_ID, - ENTITY_DISPLAY_NAME, - ENTITY_ID, - ENTITY_IDENTITY_FIELDS, - ENTITY_LAST_SEEN, - ENTITY_TYPE, -} from '@kbn/observability-shared-plugin/common'; +import { ENTITY_LATEST, entitiesAliasPattern, type EntityMetadata } from '@kbn/entities-schema'; import { decode, encode } from '@kbn/rison'; import { isRight } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; export const entityColumnIdsRt = t.union([ - t.literal(ENTITY_DISPLAY_NAME), - t.literal(ENTITY_LAST_SEEN), - t.literal(ENTITY_TYPE), + t.literal('entityDisplayName'), + t.literal('entityLastSeenTimestamp'), + t.literal('entityType'), t.literal('alertsCount'), t.literal('actions'), ]); @@ -80,23 +71,20 @@ export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ dataset: ENTITY_LATEST, }); -export interface Entity { - [ENTITY_LAST_SEEN]: string; - [ENTITY_ID]: string; - [ENTITY_TYPE]: string; - [ENTITY_DISPLAY_NAME]: string; - [ENTITY_DEFINITION_ID]: string; - [ENTITY_IDENTITY_FIELDS]: string | string[]; - alertsCount?: number; - [key: string]: any; -} - export type EntityGroup = { count: number; } & { [key: string]: string; }; -export type InventoryEntityLatest = z.infer & { +export type InventoryEntity = { + entityId: string; + entityType: string; + entityIdentityFields: string | string[]; + entityDisplayName: string; + entityDefinitionId: string; + entityLastSeenTimestamp: string; + entityDefinitionVersion: string; + entitySchemaVersion: string; alertsCount?: number; -}; +} & EntityMetadata; diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/entity_type_guards.ts b/x-pack/plugins/observability_solution/inventory/common/utils/entity_type_guards.ts new file mode 100644 index 0000000000000..dccc888abd8dc --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/utils/entity_type_guards.ts @@ -0,0 +1,25 @@ +/* + * 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 { AgentName } from '@kbn/elastic-agent-utils'; +import type { InventoryEntity } from '../entities'; + +interface BuiltinEntityMap { + host: InventoryEntity & { cloud?: { provider?: string[] } }; + container: InventoryEntity & { cloud?: { provider?: string[] } }; + service: InventoryEntity & { + agent?: { name: AgentName[] }; + service?: { environment?: string }; + }; +} + +export const isBuiltinEntityOfType = ( + type: T, + entity: InventoryEntity +): entity is BuiltinEntityMap[T] => { + return entity.entityType === type; +}; 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 deleted file mode 100644 index 758d185a5753b..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/common/utils/unflatten_entity.ts +++ /dev/null @@ -1,13 +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 { 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/e2e/cypress/e2e/home.cy.ts b/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts index 9c9011609740b..17b6cf502280a 100644 --- a/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts +++ b/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts @@ -169,6 +169,7 @@ describe('Home page', () => { 'entityTypeControlGroupOptions' ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); + cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); cy.wait('@getEEMStatus'); @@ -181,8 +182,6 @@ describe('Home page', () => { cy.get('server1').should('not.exist'); cy.contains('synth-node-trace-logs'); cy.contains('foo').should('not.exist'); - cy.getByTestSubj('inventoryGroup_entity.type_host').should('not.exist'); - cy.getByTestSubj('inventoryGroup_entity.type_container').should('not.exist'); }); it('Filters entities by host type', () => { @@ -193,6 +192,7 @@ describe('Home page', () => { 'entityTypeControlGroupOptions' ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); + cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); cy.wait('@getEEMStatus'); @@ -205,8 +205,6 @@ describe('Home page', () => { cy.contains('server1'); cy.contains('synth-node-trace-logs').should('not.exist'); cy.contains('foo').should('not.exist'); - cy.getByTestSubj('inventoryGroup_entity.type_service').should('not.exist'); - cy.getByTestSubj('inventoryGroup_entity.type_container').should('not.exist'); }); it('Filters entities by container type', () => { @@ -217,6 +215,7 @@ describe('Home page', () => { 'entityTypeControlGroupOptions' ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); + cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); cy.wait('@getEEMStatus'); @@ -229,8 +228,6 @@ describe('Home page', () => { cy.contains('server1').should('not.exist'); cy.contains('synth-node-trace-logs').should('not.exist'); cy.contains('foo'); - cy.getByTestSubj('inventoryGroup_entity.type_host').should('not.exist'); - cy.getByTestSubj('inventoryGroup_entity.type_service').should('not.exist'); }); it('Navigates to discover with actions button in the entities list', () => { 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 b5244cb29f7fc..5195a35b93f4e 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 @@ -8,11 +8,16 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { AlertsBadge } from './alerts_badge'; import { useKibana } from '../../hooks/use_kibana'; -import type { Entity } from '../../../common/entities'; +import type { InventoryEntity } from '../../../common/entities'; jest.mock('../../hooks/use_kibana'); const useKibanaMock = useKibana as jest.Mock; +const commonEntityFields: Partial = { + entityLastSeenTimestamp: 'foo', + entityId: '1', +}; + describe('AlertsBadge', () => { const mockAsKqlFilter = jest.fn(); @@ -40,16 +45,19 @@ describe('AlertsBadge', () => { }); it('render alerts badge for a host entity', () => { - 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, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'host', + entityDisplayName: 'foo', + entityIdentityFields: 'host.name', + entityDefinitionId: 'host', alertsCount: 1, + host: { + name: 'foo', + }, + cloud: { + provider: null, + }, }; mockAsKqlFilter.mockReturnValue('host.name: foo'); @@ -60,16 +68,22 @@ describe('AlertsBadge', () => { expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('1'); }); it('render alerts badge for a service entity', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'agent.name': 'node', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': 'service.name', - 'service.name': 'bar', - 'entity.definition_id': 'host', - 'cloud.provider': null, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'service', + entityDisplayName: 'foo', + entityIdentityFields: 'service.name', + entityDefinitionId: 'service', + service: { + name: 'bar', + }, + agent: { + name: 'node', + }, + cloud: { + provider: null, + }, + alertsCount: 5, }; mockAsKqlFilter.mockReturnValue('service.name: bar'); @@ -81,17 +95,22 @@ describe('AlertsBadge', () => { expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('5'); }); it('render alerts badge for a service entity with multiple identity fields', () => { - const entity: Entity = { - 'entity.last_seen_timestamp': 'foo', - 'agent.name': 'node', - 'entity.id': '1', - 'entity.type': 'service', - 'entity.display_name': 'foo', - 'entity.identity_fields': ['service.name', 'service.environment'], - 'service.name': 'bar', - 'service.environment': 'prod', - 'entity.definition_id': 'host', - 'cloud.provider': null, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'service', + entityDisplayName: 'foo', + entityIdentityFields: ['service.name', 'service.environment'], + entityDefinitionId: 'service', + service: { + name: 'bar', + environment: 'prod', + }, + agent: { + name: 'node', + }, + cloud: { + provider: null, + }, alertsCount: 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 a5845a7b42dcf..ed873bdb68c21 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,11 +8,10 @@ import React from 'react'; import rison from '@kbn/rison'; import { EuiBadge, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { Entity } from '../../../common/entities'; -import { unflattenEntity } from '../../../common/utils/unflatten_entity'; +import type { InventoryEntity } from '../../../common/entities'; import { useKibana } from '../../hooks/use_kibana'; -export function AlertsBadge({ entity }: { entity: Entity }) { +export function AlertsBadge({ entity }: { entity: InventoryEntity }) { const { services: { http: { basePath }, @@ -22,7 +21,12 @@ export function AlertsBadge({ entity }: { entity: Entity }) { const activeAlertsHref = basePath.prepend( `/app/observability/alerts?_a=${rison.encode({ - kuery: entityManager.entityClient.asKqlFilter(unflattenEntity(entity)), + kuery: entityManager.entityClient.asKqlFilter({ + entity: { + identity_fields: entity.entityIdentityFields, + }, + ...entity, + }), status: 'active', })}` ); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx index a3f2834934cd8..ae80bf09ecae2 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiDataGridSorting, EuiFlexGroup, EuiFlexItem } from '@elast import { Meta, Story } from '@storybook/react'; import { orderBy } from 'lodash'; import React, { useMemo, useState } from 'react'; -import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import { ENTITY_LAST_SEEN } from '@kbn/observability-shared-plugin/common'; import { useArgs } from '@storybook/addons'; import { EntitiesGrid } from '.'; import { entitiesMock } from './mock/entities_mock'; @@ -45,7 +45,7 @@ export const Grid: Story = (args) => { const filteredAndSortedItems = useMemo( () => orderBy( - entityType ? entitiesMock.filter((mock) => mock[ENTITY_TYPE] === entityType) : entitiesMock, + entityType ? entitiesMock.filter((mock) => mock.entityType === entityType) : entitiesMock, sort.id, sort.direction ), 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 d5d08ed415a40..29a862646c4c4 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 @@ -9,28 +9,22 @@ import React from 'react'; 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'; +import type { InventoryEntity } from '../../../../common/entities'; jest.mock('../../../hooks/use_detail_view_redirect'); const useDetailViewRedirectMock = useDetailViewRedirect as jest.Mock; describe('EntityName', () => { - 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', + const mockEntity: InventoryEntity = { + entityLastSeenTimestamp: '2023-10-09T00:00:00Z', + entityId: '1', + entityType: 'service', + entityDisplayName: 'entity_name', + entityIdentityFields: ['service.name', 'service.environment'], + entityDefinitionId: 'entity_definition_id', + entitySchemaVersion: '1', + entityDefinitionVersion: '1', }; beforeEach(() => { 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 e8db7013f8cb3..6117f6e428bde 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 @@ -7,14 +7,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import React, { useCallback } from 'react'; -import { ENTITY_DISPLAY_NAME } from '@kbn/observability-shared-plugin/common'; import { useKibana } from '../../../hooks/use_kibana'; -import type { Entity } from '../../../../common/entities'; +import type { InventoryEntity } from '../../../../common/entities'; import { EntityIcon } from '../../entity_icon'; import { useDetailViewRedirect } from '../../../hooks/use_detail_view_redirect'; interface EntityNameProps { - entity: Entity; + entity: InventoryEntity; } export function EntityName({ entity }: EntityNameProps) { @@ -29,7 +28,7 @@ export function EntityName({ entity }: EntityNameProps) { const handleLinkClick = useCallback(() => { telemetry.reportEntityViewClicked({ view_type: 'detail', - entity_type: entity['entity.type'], + entity_type: entity.entityType, }); }, [entity, telemetry]); @@ -40,7 +39,7 @@ export function EntityName({ entity }: EntityNameProps) { - {entity[ENTITY_DISPLAY_NAME]} + {entity.entityDisplayName} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx index d514dc9199aec..be5c50eba9c07 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx @@ -8,11 +8,6 @@ import { EuiButtonIcon, EuiDataGridColumn, EuiToolTip } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { - ENTITY_DISPLAY_NAME, - ENTITY_LAST_SEEN, - ENTITY_TYPE, -} from '@kbn/observability-shared-plugin/common'; const alertsLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel', { defaultMessage: 'Alerts', @@ -76,12 +71,12 @@ export const getColumns = ({ }: { showAlertsColumn: boolean; showActions: boolean; -}): EuiDataGridColumn[] => { +}) => { return [ ...(showAlertsColumn ? [ { - id: 'alertsCount', + id: 'alertsCount' as const, displayAsText: alertsLabel, isSortable: true, display: , @@ -91,21 +86,21 @@ export const getColumns = ({ ] : []), { - id: ENTITY_DISPLAY_NAME, + id: 'entityDisplayName' as const, // keep it for accessibility purposes displayAsText: entityNameLabel, display: , isSortable: true, }, { - id: ENTITY_TYPE, + id: 'entityType' as const, // keep it for accessibility purposes displayAsText: entityTypeLabel, display: , isSortable: true, }, { - id: ENTITY_LAST_SEEN, + id: 'entityLastSeenTimestamp' as const, // keep it for accessibility purposes displayAsText: entityLastSeenLabel, display: ( @@ -118,7 +113,7 @@ export const getColumns = ({ ...(showActions ? [ { - id: 'actions', + id: 'actions' as const, // keep it for accessibility purposes displayAsText: entityActionsLabel, display: ( @@ -128,5 +123,5 @@ export const getColumns = ({ }, ] : []), - ]; + ] satisfies EuiDataGridColumn[]; }; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index 7ca29f7820332..ff4329955773d 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -15,13 +15,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; import { last } from 'lodash'; import React, { useCallback, useMemo } from 'react'; -import { - ENTITY_DISPLAY_NAME, - ENTITY_LAST_SEEN, - ENTITY_TYPE, -} from '@kbn/observability-shared-plugin/common'; -import { EntityColumnIds } from '../../../common/entities'; -import { APIReturnType } from '../../api'; +import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import { EntityColumnIds, InventoryEntity } from '../../../common/entities'; import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; import { getColumns } from './grid_columns'; import { AlertsBadge } from '../alerts_badge/alerts_badge'; @@ -29,12 +24,9 @@ import { EntityName } from './entity_name'; import { EntityActions } from '../entity_actions'; import { useDiscoverRedirect } from '../../hooks/use_discover_redirect'; -type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; -type LatestEntities = InventoryEntitiesAPIReturnType['entities']; - interface Props { loading: boolean; - entities: LatestEntities; + entities: InventoryEntity[]; sortDirection: 'asc' | 'desc'; sortField: string; pageIndex: number; @@ -88,16 +80,17 @@ export function EntitiesGrid({ } const columnEntityTableId = columnId as EntityColumnIds; - const entityType = entity[ENTITY_TYPE]; + const entityType = entity.entityType; const discoverUrl = getDiscoverRedirectUrl(entity); switch (columnEntityTableId) { case 'alertsCount': return entity?.alertsCount ? : null; - case ENTITY_TYPE: + case 'entityType': return ; - case ENTITY_LAST_SEEN: + + case 'entityLastSeenTimestamp': return ( ); - case ENTITY_DISPLAY_NAME: + case 'entityDisplayName': return ; case 'actions': return ( discoverUrl && ( ) ); default: - return entity[columnId as EntityColumnIds] || ''; + return null; } }, [entities, getDiscoverRedirectUrl] diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts index 3b7e7afcadb99..1048b18f82e91 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts @@ -6,15 +6,7 @@ */ import { faker } from '@faker-js/faker'; -import { - ENTITY_DISPLAY_NAME, - ENTITY_TYPE, - ENTITY_ID, - ENTITY_LAST_SEEN, - AGENT_NAME, - CLOUD_PROVIDER, -} from '@kbn/observability-shared-plugin/common'; -import { Entity } from '../../../../common/entities'; +import type { InventoryEntity } from '../../../../common/entities'; const idGenerator = () => { let id = 0; @@ -33,38 +25,48 @@ function generateRandomTimestamp() { return randomDate.toISOString(); } -const getEntity = (entityType: string, customFields: Record = {}) => ({ - [ENTITY_LAST_SEEN]: generateRandomTimestamp(), - [ENTITY_TYPE]: entityType, - [ENTITY_DISPLAY_NAME]: faker.person.fullName(), - [ENTITY_ID]: generateId(), - ...customFields, +const indentityFieldsPerType: Record = { + host: ['host.name'], + container: ['container.id'], + service: ['service.name'], +}; + +const getEntityLatest = ( + entityType: string, + overrides?: Partial +): InventoryEntity => ({ + entityLastSeenTimestamp: generateRandomTimestamp(), + entityType, + entityDisplayName: faker.person.fullName(), + entityId: generateId(), + entityDefinitionId: faker.string.uuid(), + entityDefinitionVersion: '1.0.0', + entityIdentityFields: indentityFieldsPerType[entityType], + entitySchemaVersion: '1.0.0', + ...overrides, }); -const alertsMock = [ - { - ...getEntity('host'), - alertsCount: 3, - }, - { - ...getEntity('service'), +const alertsMock: InventoryEntity[] = [ + getEntityLatest('host', { + alertsCount: 1, + }), + getEntityLatest('service', { alertsCount: 3, - }, - - { - ...getEntity('host'), + }), + getEntityLatest('host', { alertsCount: 10, - }, - { - ...getEntity('host'), + }), + getEntityLatest('host', { alertsCount: 1, - }, + }), ]; -const hostsMock = Array.from({ length: 20 }, () => getEntity('host', { [CLOUD_PROVIDER]: 'gcp' })); -const containersMock = Array.from({ length: 20 }, () => getEntity('container')); +const hostsMock = Array.from({ length: 20 }, () => + getEntityLatest('host', { cloud: { provider: 'gcp' } }) +); +const containersMock = Array.from({ length: 20 }, () => getEntityLatest('container')); const servicesMock = Array.from({ length: 20 }, () => - getEntity('service', { [AGENT_NAME]: 'java' }) + getEntityLatest('service', { agent: { name: 'java' } }) ); export const entitiesMock = [ @@ -72,4 +74,4 @@ export const entitiesMock = [ ...hostsMock, ...containersMock, ...servicesMock, -] as Entity[]; +] as InventoryEntity[]; 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 48b21779d2e38..4da8fd3103c41 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,36 +6,23 @@ */ import React from 'react'; -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'; import { euiThemeVars } from '@kbn/ui-theme'; -import type { Entity } from '../../../common/entities'; +import { castArray } from 'lodash'; +import type { InventoryEntity } from '../../../common/entities'; +import { isBuiltinEntityOfType } from '../../../common/utils/entity_type_guards'; interface EntityIconProps { - entity: Entity; + entity: InventoryEntity; } -type NotNullableCloudProvider = Exclude; - -const getSingleValue = (value?: T | T[] | null): T | undefined => { - return value == null ? undefined : Array.isArray(value) ? value[0] : value; -}; - export function EntityIcon({ entity }: EntityIconProps) { - const entityType = entity[ENTITY_TYPE]; const defaultIconSize = euiThemeVars.euiSizeL; - if (entityType === ENTITY_TYPES.HOST || entityType === ENTITY_TYPES.CONTAINER) { - const cloudProvider = getSingleValue( - entity[CLOUD_PROVIDER] as NotNullableCloudProvider | NotNullableCloudProvider[] - ); + if (isBuiltinEntityOfType('host', entity) || isBuiltinEntityOfType('container', entity)) { + const cloudProvider = castArray(entity.cloud?.provider)[0]; + return ( ; + if (isBuiltinEntityOfType('service', entity)) { + return ; } - if (entityType.startsWith('kubernetes')) { + if (entity.entityType.startsWith('kubernetes')) { return ; } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx index b939f0fa5c423..0964b7bb39465 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx @@ -8,6 +8,7 @@ import { EuiSpacer } from '@elastic/eui'; import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { flattenObject } from '@kbn/observability-utils/object/flatten_object'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; import { useKibana } from '../../hooks/use_kibana'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; @@ -52,15 +53,18 @@ export function GroupedInventory() { <> - {value.groups.map((group) => ( - - ))} + {value.groups.map((group) => { + const groupValue = flattenObject(group)[value.groupBy]; + 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 index cf4993f871880..233c1a1076b79 100644 --- 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 @@ -9,34 +9,24 @@ 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, + HOST_NAME, SERVICE_NAME, } from '@kbn/observability-shared-plugin/common'; -import { unflattenEntity } from '../../common/utils/unflatten_entity'; -import type { Entity } from '../../common/entities'; +import type { InventoryEntity } 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', +const commonEntityFields: Partial = { + entityLastSeenTimestamp: '2023-10-09T00:00:00Z', + entityId: '1', + entityDisplayName: 'entity_name', + entityDefinitionId: 'entity_definition_id', + entityDefinitionVersion: '1', + entitySchemaVersion: '1', }; describe('useDetailViewRedirect', () => { @@ -66,17 +56,19 @@ describe('useDetailViewRedirect', () => { }, }, }); - - 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, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'host', + entityIdentityFields: ['host.name'], + host: { + name: 'host-1', + }, + cloud: { + provider: null, + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [HOST_NAME]: 'host-1' }); @@ -90,12 +82,16 @@ describe('useDetailViewRedirect', () => { }); 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, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'container', + entityIdentityFields: ['container.id'], + container: { + id: 'container-1', + }, + cloud: { + provider: null, + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [CONTAINER_ID]: 'container-1' }); @@ -112,13 +108,17 @@ describe('useDetailViewRedirect', () => { }); 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', + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType: 'service', + entityIdentityFields: ['service.name'], + agent: { + name: 'node', + }, + service: { + name: 'service-1', + environment: 'prod', + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [SERVICE_NAME]: 'service-1' }); mockGetRedirectUrl.mockReturnValue('service-overview-url'); @@ -145,10 +145,13 @@ describe('useDetailViewRedirect', () => { [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, + const entity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityType, + entityIdentityFields: ['some.field'], + some: { + field: 'some-value', + }, }; mockAsKqlFilter.mockReturnValue('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 index 23380dc3704de..4df4fa4ca1f96 100644 --- 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 @@ -6,20 +6,17 @@ */ import { ASSET_DETAILS_LOCATOR_ID, - AssetDetailsLocatorParams, - ENTITY_IDENTITY_FIELDS, - ENTITY_TYPE, ENTITY_TYPES, - SERVICE_ENVIRONMENT, SERVICE_OVERVIEW_LOCATOR_ID, - ServiceOverviewParams, + type AssetDetailsLocatorParams, + type ServiceOverviewParams, } from '@kbn/observability-shared-plugin/common'; import { useCallback } from 'react'; -import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public'; +import type { 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 { isBuiltinEntityOfType } from '../../common/utils/entity_type_guards'; +import type { InventoryEntity } from '../../common/entities'; import { useKibana } from './use_kibana'; const KUBERNETES_DASHBOARDS_IDS: Record = { @@ -44,52 +41,38 @@ export const useDetailViewRedirect = () => { 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, - }); + (entity: InventoryEntity) => { + const identityFieldsValue = entityManager.entityClient.getIdentityFieldsValue({ + entity: { + identity_fields: entity.entityIdentityFields, + }, + ...entity, + }); + const identityFields = castArray(entity.entityIdentityFields); - 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], - }); + if (isBuiltinEntityOfType('host', entity) || isBuiltinEntityOfType('container', entity)) { + return assetDetailsLocator?.getRedirectUrl({ + assetId: identityFieldsValue[identityFields[0]], + assetType: entity.entityType, + }); + } - default: - return undefined; + if (isBuiltinEntityOfType('service', entity)) { + return serviceOverviewLocator?.getRedirectUrl({ + serviceName: identityFieldsValue[identityFields[0]], + environment: entity.service?.environment, + }); } + + return undefined; }, - [assetDetailsLocator, getSingleIdentityFieldValue, serviceOverviewLocator] + [assetDetailsLocator, entityManager.entityClient, serviceOverviewLocator] ); const getDashboardRedirectUrl = useCallback( - (entity: Entity) => { - const type = entity[ENTITY_TYPE]; + (entity: InventoryEntity) => { + const type = entity.entityType; const dashboardId = KUBERNETES_DASHBOARDS_IDS[type]; return dashboardId @@ -97,7 +80,12 @@ export const useDetailViewRedirect = () => { dashboardId, query: { language: 'kuery', - query: entityManager.entityClient.asKqlFilter(unflattenEntity(entity)), + query: entityManager.entityClient.asKqlFilter({ + entity: { + identity_fields: entity.entityIdentityFields, + }, + ...entity, + }), }, }) : undefined; @@ -106,7 +94,8 @@ export const useDetailViewRedirect = () => { ); const getEntityRedirectUrl = useCallback( - (entity: Entity) => getDetailViewRedirectUrl(entity) ?? getDashboardRedirectUrl(entity), + (entity: InventoryEntity) => + getDetailViewRedirectUrl(entity) ?? getDashboardRedirectUrl(entity), [getDashboardRedirectUrl, getDetailViewRedirectUrl] ); diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts index c29caca7e5b77..33758c9df449d 100644 --- a/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts @@ -11,12 +11,11 @@ import { ENTITY_TYPE, } from '@kbn/observability-shared-plugin/common'; import { useCallback } from 'react'; -import type { Entity, EntityColumnIds } from '../../common/entities'; -import { unflattenEntity } from '../../common/utils/unflatten_entity'; +import type { InventoryEntity } from '../../common/entities'; import { useKibana } from './use_kibana'; import { useUnifiedSearchContext } from './use_unified_search_context'; -const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; +const ACTIVE_COLUMNS = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; export const useDiscoverRedirect = () => { const { @@ -31,9 +30,14 @@ export const useDiscoverRedirect = () => { const discoverLocator = share.url.locators.get('DISCOVER_APP_LOCATOR'); const getDiscoverEntitiesRedirectUrl = useCallback( - (entity?: Entity) => { + (entity?: InventoryEntity) => { const entityKqlFilter = entity - ? entityManager.entityClient.asKqlFilter(unflattenEntity(entity)) + ? entityManager.entityClient.asKqlFilter({ + entity: { + identity_fields: entity.entityIdentityFields, + }, + ...entity, + }) : ''; const kueryWithEntityDefinitionFilters = [ @@ -65,7 +69,7 @@ export const useDiscoverRedirect = () => { ); const getDiscoverRedirectUrl = useCallback( - (entity?: Entity) => getDiscoverEntitiesRedirectUrl(entity), + (entity?: InventoryEntity) => getDiscoverEntitiesRedirectUrl(entity), [getDiscoverEntitiesRedirectUrl] ); diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts index 8c72e18bc0740..bab4af50e316e 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts @@ -7,7 +7,6 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; import { ENTITIES_LATEST_ALIAS, type EntityGroup, @@ -32,10 +31,8 @@ export async function getEntityGroupsBy({ const limit = `LIMIT ${MAX_NUMBER_OF_ENTITIES}`; const query = [from, ...where, group, sort, limit].join(' | '); - const groups = await inventoryEsClient.esql('get_entities_groups', { + return inventoryEsClient.esql('get_entities_groups', { query, filter: esQuery, }); - - return esqlResultToPlainObjects(groups); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts index 2dfc9b8ccfdf3..99b8829b600b2 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts @@ -7,6 +7,7 @@ import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import type { EntityInstance } from '@kbn/entities-schema'; import { ENTITIES_LATEST_ALIAS } from '../../../common/entities'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper'; @@ -15,12 +16,14 @@ export async function getEntityTypes({ }: { inventoryEsClient: ObservabilityElasticsearchClient; }) { - const entityTypesEsqlResponse = await inventoryEsClient.esql('get_entity_types', { + const entityTypesEsqlResponse = await inventoryEsClient.esql<{ + entity: Pick; + }>('get_entity_types', { query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getBuiltinEntityDefinitionIdESQLWhereClause()} | STATS count = COUNT(${ENTITY_TYPE}) BY ${ENTITY_TYPE} `, }); - return entityTypesEsqlResponse.values.map(([_, val]) => val as string); + return entityTypesEsqlResponse.map((response) => response.entity.type); } 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 62d77c08fd27a..8b6b3b109352c 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 @@ -5,52 +5,60 @@ * 2.0. */ -import type { Entity } from '../../../common/entities'; -import { - ENTITY_DEFINITION_ID, - ENTITY_DISPLAY_NAME, - ENTITY_ID, - ENTITY_IDENTITY_FIELDS, - ENTITY_LAST_SEEN, -} from '@kbn/observability-shared-plugin/common'; +import type { InventoryEntity } from '../../../common/entities'; import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; -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, +const commonEntityFields: Partial = { + entityLastSeenTimestamp: '2023-10-09T00:00:00Z', + entityId: '1', + entityDisplayName: 'entity_name', + entityDefinitionId: 'entity_definition_id', + alertsCount: 3, }; + describe('getIdentityFields', () => { it('should return an empty Map when no entities are provided', () => { const result = getIdentityFieldsPerEntityType([]); expect(result.size).toBe(0); }); 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'], - 'service.name': 'my-service', - 'entity.type': 'service', - ...commonEntityFields, + const serviceEntity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityIdentityFields: ['service.name', 'service.environment'], + entityType: 'service', + agent: { + name: 'node', + }, + service: { + name: 'my-service', + }, }; - const hostEntity: Entity = { - [ENTITY_IDENTITY_FIELDS]: ['host.name'], - 'host.name': 'my-host', - 'entity.type': 'host', - 'cloud.provider': null, - ...commonEntityFields, + const hostEntity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityIdentityFields: ['host.name'], + entityType: 'host', + cloud: { + provider: null, + }, + host: { + name: 'my-host', + }, }; - const containerEntity: Entity = { - [ENTITY_IDENTITY_FIELDS]: 'container.id', - 'host.name': 'my-host', - 'entity.type': 'container', - 'cloud.provider': null, - 'container.id': '123', - ...commonEntityFields, + const containerEntity: InventoryEntity = { + ...(commonEntityFields as InventoryEntity), + entityIdentityFields: ['container.id'], + entityType: 'container', + host: { + name: 'my-host', + }, + cloud: { + provider: null, + }, + container: { + id: '123', + }, }; const mockEntities = [serviceEntity, hostEntity, containerEntity]; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts index f54dc8a7f121f..06070b66bad1f 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts @@ -5,16 +5,16 @@ * 2.0. */ -import { ENTITY_IDENTITY_FIELDS, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; -import { Entity } from '../../../common/entities'; +import { castArray } from 'lodash'; +import type { InventoryEntity } from '../../../common/entities'; export type IdentityFieldsPerEntityType = Map; -export const getIdentityFieldsPerEntityType = (entities: Entity[]) => { - const identityFieldsPerEntityType: IdentityFieldsPerEntityType = new Map(); +export const getIdentityFieldsPerEntityType = (latestEntities: InventoryEntity[]) => { + const identityFieldsPerEntityType = new Map(); - entities.forEach((entity) => - identityFieldsPerEntityType.set(entity[ENTITY_TYPE], [entity[ENTITY_IDENTITY_FIELDS]].flat()) + latestEntities.forEach((entity) => + identityFieldsPerEntityType.set(entity.entityType, castArray(entity.entityIdentityFields)) ); return identityFieldsPerEntityType; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index 402d11720a9da..7a65ac5039615 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -5,18 +5,32 @@ * 2.0. */ +import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { + ENTITY_LAST_SEEN, + ENTITY_TYPE, + ENTITY_DISPLAY_NAME, +} from '@kbn/observability-shared-plugin/common'; import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types'; -import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; -import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; +import type { EntityInstance } from '@kbn/entities-schema'; import { ENTITIES_LATEST_ALIAS, MAX_NUMBER_OF_ENTITIES, - type Entity, type EntityColumnIds, + InventoryEntity, } from '../../../common/entities'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper'; +type EntitySortableColumnIds = Extract< + EntityColumnIds, + 'entityLastSeenTimestamp' | 'entityDisplayName' | 'entityType' +>; +const SORT_FIELDS_TO_ES_FIELDS: Record = { + entityLastSeenTimestamp: ENTITY_LAST_SEEN, + entityDisplayName: ENTITY_DISPLAY_NAME, + entityType: ENTITY_TYPE, +} as const; + export async function getLatestEntities({ inventoryEsClient, sortDirection, @@ -29,9 +43,10 @@ export async function getLatestEntities({ sortField: EntityColumnIds; esQuery?: QueryDslQueryContainer; entityTypes?: string[]; -}) { +}): Promise { // alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default. - const entitiesSortField = sortField === 'alertsCount' ? ENTITY_LAST_SEEN : sortField; + const entitiesSortField = + SORT_FIELDS_TO_ES_FIELDS[sortField as EntitySortableColumnIds] ?? ENTITY_LAST_SEEN; const from = `FROM ${ENTITIES_LATEST_ALIAS}`; const where: string[] = [getBuiltinEntityDefinitionIdESQLWhereClause()]; @@ -47,11 +62,28 @@ export async function getLatestEntities({ const query = [from, ...where, sort, limit].join(' | '); - const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { - query, - filter: esQuery, - params, - }); + const latestEntitiesEsqlResponse = await inventoryEsClient.esql( + 'get_latest_entities', + { + query, + filter: esQuery, + params, + } + ); - return esqlResultToPlainObjects(latestEntitiesEsqlResponse); + return latestEntitiesEsqlResponse.map((lastestEntity) => { + const { entity, ...metadata } = lastestEntity; + + return { + entityId: entity.id, + entityType: entity.type, + entityDefinitionId: entity.definition_id, + entityDisplayName: entity.display_name, + entityIdentityFields: entity.identity_fields, + entityLastSeenTimestamp: entity.last_seen_timestamp, + entityDefinitionVersion: entity.definition_version, + entitySchemaVersion: entity.schema_version, + ...metadata, + }; + }); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts index 8126c69de6922..0f1ace0407233 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts @@ -6,7 +6,6 @@ */ import { termQuery } from '@kbn/observability-plugin/server'; -import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import { ALERT_STATUS, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; import { AlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client'; import { getGroupByTermsAgg } from './get_group_by_terms_agg'; @@ -25,7 +24,7 @@ export async function getLatestEntitiesAlerts({ }: { alertsClient: AlertsClient; identityFieldsPerEntityType: IdentityFieldsPerEntityType; -}): Promise> { +}): Promise> { if (identityFieldsPerEntityType.size === 0) { return []; } @@ -54,7 +53,7 @@ export async function getLatestEntitiesAlerts({ return buckets.map((bucket: Bucket) => ({ alertsCount: bucket.doc_count, - [ENTITY_TYPE]: entityType, + entityType, ...bucket.key, })); }); diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts index ae99713375b19..bdf0b1f59af01 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts @@ -11,7 +11,7 @@ import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import * as t from 'io-ts'; import { orderBy } from 'lodash'; import { joinByKey } from '@kbn/observability-utils/array/join_by_key'; -import { entityColumnIdsRt, Entity } from '../../../common/entities'; +import { entityColumnIdsRt, InventoryEntity } from '../../../common/entities'; import { createInventoryServerRoute } from '../create_inventory_server_route'; import { getEntityTypes } from './get_entity_types'; import { getLatestEntities } from './get_latest_entities'; @@ -61,7 +61,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ logger, plugins, request, - }): Promise<{ entities: Entity[] }> => { + }): Promise<{ entities: InventoryEntity[] }> => { const coreContext = await context.core; const inventoryEsClient = createObservabilityEsClient({ client: coreContext.elasticsearch.client.asCurrentUser, @@ -90,16 +90,16 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ }); const joined = joinByKey( - [...latestEntities, ...alerts], + [...latestEntities, ...alerts] as InventoryEntity[], [...identityFieldsPerEntityType.values()].flat() - ).filter((entity) => entity['entity.id']) as Entity[]; + ).filter((latestEntity) => latestEntity.entityId); return { entities: sortField === 'alertsCount' ? orderBy( joined, - [(item: Entity) => item?.alertsCount === undefined, sortField], + [(item: InventoryEntity) => item?.alertsCount === undefined, sortField], ['asc', sortDirection] // push entities without alertsCount to the end ) : joined, 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 c1e4a82c343b0..d328a4f3b8d6f 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 @@ -5,7 +5,6 @@ * 2.0. */ import type { Logger } from '@kbn/core/server'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from '../entities/query_helper'; import { ENTITIES_LATEST_ALIAS } from '../../../common/entities'; @@ -18,14 +17,15 @@ export async function getHasData({ logger: Logger; }) { try { - const esqlResults = await inventoryEsClient.esql('get_has_data', { + const esqlResults = await inventoryEsClient.esql<{ _count: number }>('get_has_data', { query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getBuiltinEntityDefinitionIdESQLWhereClause()} | STATS _count = COUNT(*) | LIMIT 1`, }); - const totalCount = esqlResultToPlainObjects(esqlResults)?.[0]._count ?? 0; + const totalCount = esqlResults[0]._count; + 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 5cb95e8ac6de5..e9949e60201c8 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -53,7 +53,6 @@ "@kbn/spaces-plugin", "@kbn/cloud-plugin", "@kbn/storybook", - "@kbn/zod", "@kbn/dashboard-plugin", "@kbn/deeplinks-analytics", "@kbn/controls-plugin",