From a62728d8b1bc5c00a00f93b5d8efd3a276a9a9bf Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Fri, 25 Oct 2024 10:22:33 +0200 Subject: [PATCH] Improve types --- packages/kbn-es-types/src/search.ts | 9 +- .../kbn-entities-schema/src/schema/entity.ts | 2 +- .../client/create_observability_es_client.ts | 10 +- .../es/utils/esql_result_to_plain_objects.ts | 17 ++-- .../inventory/common/entities.ts | 22 +++++ .../public/components/entity_icon/index.tsx | 30 +++--- .../hooks/use_detail_view_redirect.test.ts | 99 ++++++++++--------- .../public/hooks/use_detail_view_redirect.ts | 45 +++++---- .../routes/entities/get_entity_types.ts | 13 ++- .../get_identity_fields_per_entity_type.ts | 3 +- .../routes/entities/get_latest_entities.ts | 32 +++--- .../server/routes/has_data/get_has_data.ts | 6 +- 12 files changed, 158 insertions(+), 130 deletions(-) diff --git a/packages/kbn-es-types/src/search.ts b/packages/kbn-es-types/src/search.ts index 87f9dd15517c9..42d870cae1084 100644 --- a/packages/kbn-es-types/src/search.ts +++ b/packages/kbn-es-types/src/search.ts @@ -674,12 +674,15 @@ export interface ESQLColumn { export type ESQLRow = unknown[]; -export interface ESQLSearchResponse { - columns: ESQLColumn[]; +export interface ESQLSearchResponse< + TColumn extends ESQLColumn = ESQLColumn, + TRow extends ESQLRow = ESQLRow +> { + columns: TColumn[]; // In case of ?drop_null_columns in the query, then // all_columns will have available and empty fields // while columns only the available ones (non nulls) - all_columns?: ESQLColumn[]; + all_columns?: TRow[]; values: ESQLRow[]; took?: number; _clusters?: estypes.ClusterStatistics; 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 c758077f2fc63..f996f088df7ef 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts @@ -11,7 +11,7 @@ import { arrayOfStringsSchema } from './common'; export const entityBaseSchema = z.object({ id: z.string(), type: z.string(), - identityFields: arrayOfStringsSchema, + identityFields: z.union([arrayOfStringsSchema, z.string()]), displayName: z.string(), metrics: z.optional(z.record(z.string(), z.number())), definitionVersion: z.string(), 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..efee7766ac1d2 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[]; @@ -25,7 +26,10 @@ export interface ObservabilityElasticsearchClient { operationName: string, parameters: TSearchRequest ): Promise>; - esql(operationName: string, parameters: EsqlQueryRequest): Promise; + esql( + operationName: string, + parameters: EsqlQueryRequest + ): Promise; client: ElasticsearchClient; } @@ -40,7 +44,7 @@ export function createObservabilityEsClient({ }): ObservabilityElasticsearchClient { return { client, - esql(operationName: string, parameters: EsqlQueryRequest) { + esql(operationName: string, parameters: EsqlQueryRequest) { logger.trace(() => `Request (${operationName}):\n${JSON.stringify(parameters, null, 2)}`); return withSpan({ name: operationName, labels: { plugin } }, () => { return client.esql.query( @@ -54,7 +58,7 @@ export function createObservabilityEsClient({ }) .then((response) => { logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`); - return response as unknown as ESQLSearchResponse; + return esqlResultToPlainObjects(response as unknown as ESQLSearchResponse); }) .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 6f3e5ea726cab..2333854421016 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,17 +6,14 @@ */ import type { ESQLSearchResponse } from '@kbn/es-types'; -import { castArray } from 'lodash'; import { unflattenObject } from '../../object/unflatten_object'; -export function esqlResultToPlainObjects>( - result: ESQLSearchResponse, - knownArrayFields?: string[] -): T[] { - const knownArrayFieldsSet = new Set(knownArrayFields); +export function esqlResultToPlainObjects( + result: ESQLSearchResponse +): TDocument[] { return result.values.map((row) => { return unflattenObject( - row.reduce>((acc, value, index) => { + row.reduce>((acc, value, index) => { const column = result.columns[index]; if (!column) { @@ -26,11 +23,11 @@ export function esqlResultToPlainObjects>( // Removes the type suffix from the column name const name = column.name.replace(/\.(text|keyword)$/, ''); if (!acc[name]) { - acc[column.name] = knownArrayFieldsSet.has(column.name) ? castArray(value) : value; + acc[column.name] = value; } return acc; }, {}) - ); - }) as T[]; + ) as TDocument; + }); } diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index f686490b90bfc..3e1c2c838360d 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -17,6 +17,7 @@ import { import { decode, encode } from '@kbn/rison'; import { isRight } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; +import { AgentName } from '@kbn/elastic-agent-utils'; export const entityColumnIdsRt = t.union([ t.literal(ENTITY_DISPLAY_NAME), @@ -122,3 +123,24 @@ export type EntityGroup = { export type InventoryEntityLatest = z.infer & { alertsCount?: number; }; + +export const isHostEntity = ( + entity: InventoryEntityLatest +): entity is InventoryEntityLatest & { cloud?: { provider: string } } => { + return entity.entity.type === 'host'; +}; + +export const isContainerEntity = ( + entity: InventoryEntityLatest +): entity is InventoryEntityLatest & { cloud?: { provider: string } } => { + return entity.entity.type === 'container'; +}; + +export const isServiceEntity = ( + entity: InventoryEntityLatest +): entity is InventoryEntityLatest & { + agent?: { name: AgentName }; + service: { name: string; environment: string }; +} => { + return entity.entity.type === 'service'; +}; 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 fa7720ae1c406..860c6c5ca2e86 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,31 +6,26 @@ */ import React from 'react'; -import { AGENT_NAME, CLOUD_PROVIDER, 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 { InventoryEntityLatest } from '../../../common/entities'; +import { + isHostEntity, + type InventoryEntityLatest, + isContainerEntity, + isServiceEntity, +} from '../../../common/entities'; interface EntityIconProps { entity: InventoryEntityLatest; } -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 (isHostEntity(entity) || isContainerEntity(entity)) { + const cloudProvider = entity.cloud?.provider; + return ( ; + if (isServiceEntity(entity)) { + return ; } if (entityType.startsWith('kubernetes')) { 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..0cc68bcea5ee7 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 @@ -8,23 +8,9 @@ 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 { CONTAINER_ID, 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 { InventoryEntityLatest } from '../../common/entities'; jest.mock('./use_kibana'); jest.mock('../../common/utils/unflatten_entity'); @@ -32,11 +18,13 @@ 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 = { + lastSeenTimestamp: '2023-10-09T00:00:00Z', + id: '1', + displayName: 'entity_name', + definitionId: 'entity_definition_id', + definitionVersion: '1', + schemaVersion: '1', }; describe('useDetailViewRedirect', () => { @@ -71,12 +59,18 @@ describe('useDetailViewRedirect', () => { }); 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: InventoryEntityLatest = { + entity: { + ...(commonEntityFields as InventoryEntityLatest['entity']), + type: 'host', + identityFields: ['host.name'], + }, + host: { + name: 'host-1', + }, + cloud: { + provider: null, + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [HOST_NAME]: 'host-1' }); @@ -90,12 +84,18 @@ 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: InventoryEntityLatest = { + entity: { + ...(commonEntityFields as InventoryEntityLatest['entity']), + type: 'container', + identityFields: ['container.id'], + }, + container: { + id: 'container-1', + }, + cloud: { + provider: null, + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [CONTAINER_ID]: 'container-1' }); @@ -112,13 +112,19 @@ 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: InventoryEntityLatest = { + entity: { + ...(commonEntityFields as InventoryEntityLatest['entity']), + type: 'service', + identityFields: ['service.name'], + }, + agent: { + name: 'node', + }, + service: { + name: 'service-1', + environment: 'prod', + }, }; mockGetIdentityFieldsValue.mockReturnValue({ [SERVICE_NAME]: 'service-1' }); mockGetRedirectUrl.mockReturnValue('service-overview-url'); @@ -145,10 +151,15 @@ 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: InventoryEntityLatest = { + entity: { + ...(commonEntityFields as InventoryEntityLatest['entity']), + type: entityType, + identityFields: ['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 234f92233b393..e6ca1a359f81a 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 @@ -15,7 +15,12 @@ import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { castArray } from 'lodash'; import { Exception } from 'handlebars'; -import type { InventoryEntityLatest } from '../../common/entities'; +import { + isHostEntity, + type InventoryEntityLatest, + isContainerEntity, + isServiceEntity, +} from '../../common/entities'; import { useKibana } from './use_kibana'; const KUBERNETES_DASHBOARDS_IDS: Record = { @@ -42,7 +47,7 @@ export const useDetailViewRedirect = () => { const getSingleIdentityFieldValue = useCallback( (latestEntity: InventoryEntityLatest) => { - const identityFields = castArray(latestEntity.entity.type); + const identityFields = castArray(latestEntity.entity.identityFields); if (identityFields.length > 1) { throw new Exception( `Multiple identity fields are not supported for ${latestEntity.entity.type}` @@ -57,31 +62,25 @@ export const useDetailViewRedirect = () => { const getDetailViewRedirectUrl = useCallback( (latestEntity: InventoryEntityLatest) => { - const type = latestEntity.entity.type; const identityValue = getSingleIdentityFieldValue(latestEntity); - switch (entity.entity.type) { - case ENTITY_TYPES.HOST: - case ENTITY_TYPES.CONTAINER: - return assetDetailsLocator?.getRedirectUrl({ - assetId: identityValue, - assetType: entity.entity.type, - }); - - case ENTITY_TYPES.SERVICE: - return serviceOverviewLocator?.getRedirectUrl({ - serviceName: identityValue, - // service.environemnt is not part of entity.identityFields - // we need to manually get its value - environment: - 'service' in latestEntity - ? (latestEntity.service as { environment?: string }).environment - : undefined, - }); + if (isHostEntity(latestEntity) || isContainerEntity(latestEntity)) { + return assetDetailsLocator?.getRedirectUrl({ + assetId: identityValue, + assetType: latestEntity.entity.type, + }); + } - default: - return undefined; + if (isServiceEntity(latestEntity)) { + return serviceOverviewLocator?.getRedirectUrl({ + serviceName: identityValue, + // service.environemnt is not part of entity.identityFields + // we need to manually get its value + environment: latestEntity.service.environment, + }); } + + return undefined; }, [assetDetailsLocator, getSingleIdentityFieldValue, serviceOverviewLocator] ); 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..32631e062d9b1 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 @@ -15,12 +15,15 @@ export async function getEntityTypes({ }: { inventoryEsClient: ObservabilityElasticsearchClient; }) { - const entityTypesEsqlResponse = await inventoryEsClient.esql('get_entity_types', { - query: `FROM ${ENTITIES_LATEST_ALIAS} + const entityTypesEsqlResponse = await inventoryEsClient.esql<{ [ENTITY_TYPE]: string }>( + 'get_entity_types', + { + query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getBuiltinEntityDefinitionIdESQLWhereClause()} - | STATS count = COUNT(${ENTITY_TYPE}) BY ${ENTITY_TYPE} + | STATS BY ${ENTITY_TYPE} `, - }); + } + ); - return entityTypesEsqlResponse.values.map(([_, val]) => val as string); + return entityTypesEsqlResponse.flatMap((types) => Object.values(types)); } 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 269f98595331e..b755bc87e530e 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,6 +5,7 @@ * 2.0. */ +import { castArray } from 'lodash'; import { InventoryEntityLatest } from '../../../common/entities'; export type IdentityFieldsPerEntityType = Map; @@ -13,7 +14,7 @@ export const getIdentityFieldsPerEntityType = (entities: InventoryEntityLatest[] const identityFieldsPerEntityType = new Map(); entities.forEach((entity) => - identityFieldsPerEntityType.set(entity.entity.type, entity.entity.identityFields) + identityFieldsPerEntityType.set(entity.entity.type, castArray(entity.entity.identityFields)) ); 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 7656bac33f8de..1e4d3b341a0c3 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,17 +5,10 @@ * 2.0. */ -import { z } from '@kbn/zod'; import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; -import { - ENTITY_IDENTITY_FIELDS, - ENTITY_LAST_SEEN, - ENTITY_TYPE, -} from '@kbn/observability-shared-plugin/common'; +import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import { ScalarValue } from '@elastic/elasticsearch/lib/api/types'; -import { entityLatestSchema } from '@kbn/entities-schema'; import { ENTITIES_LATEST_ALIAS, MAX_NUMBER_OF_ENTITIES, @@ -54,17 +47,18 @@ export async function getLatestEntities({ const query = [from, ...where, sort, limit].join(' | '); - const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { - query, - filter: { - bool: { - filter: [...kqlQuery(kuery)], + const latestEntitiesEsqlResponse = await inventoryEsClient.esql( + 'get_latest_entities', + { + query, + filter: { + bool: { + filter: [...kqlQuery(kuery)], + }, }, - }, - params, - }); + params, + } + ); - return z - .array(entityLatestSchema) - .parse(esqlResultToPlainObjects(latestEntitiesEsqlResponse, [ENTITY_IDENTITY_FIELDS])); + return latestEntitiesEsqlResponse; } 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);