[8.x] [Inventory][ECO] Replace Entity with InventoryEntityLatest type (#198760) (#199967)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Inventory][ECO] Replace `Entity` with `InventoryEntityLatest` type
(#198760)](https://github.com/elastic/kibana/pull/198760)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Carlos
Crespo","email":"crespocarlos@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-11-12T17:47:00Z","message":"[Inventory][ECO]
Replace `Entity` with `InventoryEntityLatest` type (#198760)\n\ncloses
[#198758](https://github.com/elastic/kibana/issues/198758)\r\n\r\n##
Summary\r\n\r\nThis PR removes the Entity type used across the Inventory
and replaces\r\nit with `InventoryEntityLatest`, which provides strong
typing for the\r\nlatest entity object. This change makes the code
leverage TypeScript’s\r\nintellisense and autocompletion in the editor,
making the code easier to\r\nwork with and more maintainable across the
codebase.\r\n\r\n`InventoryEntityLatest` is the interface that the API
returns and what\r\nthe UI consumes. Note that this is distinct from the
index mapping\r\ndefined by `entityLatestSchema`, creating a separation
layer between\r\nElasticsearch and the
UI.\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4d3de83162904d3db19e82720b2dd747dcfc5e6","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor","ci:project-deploy-observability","Team:obs-ux-infra_services"],"number":198760,"url":"https://github.com/elastic/kibana/pull/198760","mergeCommit":{"message":"[Inventory][ECO]
Replace `Entity` with `InventoryEntityLatest` type (#198760)\n\ncloses
[#198758](https://github.com/elastic/kibana/issues/198758)\r\n\r\n##
Summary\r\n\r\nThis PR removes the Entity type used across the Inventory
and replaces\r\nit with `InventoryEntityLatest`, which provides strong
typing for the\r\nlatest entity object. This change makes the code
leverage TypeScript’s\r\nintellisense and autocompletion in the editor,
making the code easier to\r\nwork with and more maintainable across the
codebase.\r\n\r\n`InventoryEntityLatest` is the interface that the API
returns and what\r\nthe UI consumes. Note that this is distinct from the
index mapping\r\ndefined by `entityLatestSchema`, creating a separation
layer between\r\nElasticsearch and the
UI.\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4d3de83162904d3db19e82720b2dd747dcfc5e6"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/198760","number":198760,"mergeCommit":{"message":"[Inventory][ECO]
Replace `Entity` with `InventoryEntityLatest` type (#198760)\n\ncloses
[#198758](https://github.com/elastic/kibana/issues/198758)\r\n\r\n##
Summary\r\n\r\nThis PR removes the Entity type used across the Inventory
and replaces\r\nit with `InventoryEntityLatest`, which provides strong
typing for the\r\nlatest entity object. This change makes the code
leverage TypeScript’s\r\nintellisense and autocompletion in the editor,
making the code easier to\r\nwork with and more maintainable across the
codebase.\r\n\r\n`InventoryEntityLatest` is the interface that the API
returns and what\r\nthe UI consumes. Note that this is distinct from the
index mapping\r\ndefined by `entityLatestSchema`, creating a separation
layer between\r\nElasticsearch and the
UI.\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4d3de83162904d3db19e82720b2dd747dcfc5e6"}}]}]
BACKPORT-->
This commit is contained in:
Carlos Crespo 2024-11-13 13:06:37 +01:00 committed by GitHub
parent 12714317ba
commit a68248ccfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 578 additions and 443 deletions

View file

@ -58,6 +58,8 @@ export class K8sEntity extends Serializable<EntityFields> {
'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',
});
}
}

View file

@ -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,
},

View file

@ -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<typeof literalSchema>;
type Metadata = Literal | { [key: string]: Metadata } | Metadata[];
interface Metadata {
[key: string]: Metadata | Literal | Literal[];
}
export const entityMetadataSchema: z.ZodType<Metadata> = 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<typeof entityLatestSchema>;
export type EntityMetadata = z.infer<typeof entityMetadataSchema>;

View file

@ -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',
},
},
]);
});
});

View file

@ -18,18 +18,29 @@ export type JoinedReturnType<
}
>;
type ArrayOrSingle<T> = 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> = T extends object
? { [K in keyof T]: K extends string ? `${K}` | `${K}.${NestedKeys<T[K]>}` : never }[keyof T]
: never;
type ArrayOrSingle<T> = T | T[];
type CombinedNestedKeys<T, U> = (NestedKeys<T> & NestedKeys<U>) | (keyof T & keyof U);
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>
V extends ArrayOrSingle<CombinedNestedKeys<T, U>>
>(items: T[], key: V): JoinedReturnType<T, U>;
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>,
V extends ArrayOrSingle<CombinedNestedKeys<T, U>>,
W extends JoinedReturnType<T, U>,
X extends (a: T, b: T) => ValuesType<W>
>(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);

View file

@ -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<EsqlQueryRequest, 'format' | 'columnar'> & {
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<InferSearchResponseOf<TDocument, TSearchRequest>>;
esql(operationName: string, parameters: EsqlQueryRequest): Promise<ESQLSearchResponse>;
esql<TOutput = unknown, TQueryParams extends EsqlOutputParameters = EsqlOutputParameters>(
operationName: string,
parameters: TQueryParams
): Promise<InferEsqlResponseOf<TOutput, TQueryParams>>;
esql<TOutput = unknown, TQueryParams extends EsqlQueryParameters = EsqlQueryParameters>(
operationName: string,
parameters: TQueryParams
): Promise<InferEsqlResponseOf<TOutput, TQueryParams>>;
client: ElasticsearchClient;
}
@ -40,11 +62,14 @@ export function createObservabilityEsClient({
}): ObservabilityElasticsearchClient {
return {
client,
esql(operationName: string, parameters: EsqlQueryRequest) {
esql<TOutput = unknown, TSearchRequest extends EsqlParameters = EsqlParameters>(
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<TOutput>(esqlResponse) : esqlResponse;
})
.catch((error) => {
throw error;

View file

@ -6,25 +6,28 @@
*/
import type { ESQLSearchResponse } from '@kbn/es-types';
import { unflattenObject } from '../../object/unflatten_object';
export function esqlResultToPlainObjects<T extends Record<string, any>>(
export function esqlResultToPlainObjects<TDocument = unknown>(
result: ESQLSearchResponse
): T[] {
): TDocument[] {
return result.values.map((row) => {
return row.reduce<Record<string, unknown>>((acc, value, index) => {
const column = result.columns[index];
return unflattenObject(
row.reduce<Record<string, any>>((acc, value, index) => {
const column = result.columns[index];
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;
}
if (!column) {
return acc;
}
// Removes the type suffix from the column name
const name = column.name.replace(/\.(text|keyword)$/, '');
if (!acc[name]) {
acc[name] = value;
}
return acc;
}, {});
}) as T[];
}, {})
) as TDocument;
});
}

View file

@ -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,
};

View file

@ -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<EntityManagerRouteRepository, 'PUT /internal/entities/managed/enablement'>
>;
export type EnitityInstance = z.infer<typeof entityLatestSchema>;
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<EntityInstance['entity'], 'identity_fields'>;
} & Required<EntityMetadata>
) {
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<EntityInstance['entity'], 'identity_fields'>;
} & Required<EntityMetadata>
) {
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<string, any>)[part] : undefined;
}, entityLatest);
}, entityInstance);
if (value) {
acc[field] = value;

View file

@ -116,8 +116,11 @@ describe.skip('Transaction details', () => {
})}`
);
cy.contains('Top 5 errors');
cy.getByTestSubj('topErrorsForTransactionTable').contains('a', '[MockError] Foo').click();
cy.contains('Top 5 errors', { timeout: 30000 });
cy.getByTestSubj('topErrorsForTransactionTable')
.should('be.visible')
.contains('a', '[MockError] Foo', { timeout: 10000 })
.click();
cy.url().should('include', 'opbeans-java/errors');
});

View file

@ -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 = {

View file

@ -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);
});
}

View file

@ -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<Entity | undefined> {
}): Promise<EntitySourceResponse | undefined> {
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<Entity>(latestEntitiesEsqlResponse)[0];
return { sourceDataStreamType: response[0].source_data_stream?.type };
}

View file

@ -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: {

View file

@ -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<typeof entityLatestSchema> & {
export type InventoryEntity = {
entityId: string;
entityType: string;
entityIdentityFields: string | string[];
entityDisplayName: string;
entityDefinitionId: string;
entityLastSeenTimestamp: string;
entityDefinitionVersion: string;
entitySchemaVersion: string;
alertsCount?: number;
};
} & EntityMetadata;

View file

@ -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 = <T extends keyof BuiltinEntityMap>(
type: T,
entity: InventoryEntity
): entity is BuiltinEntityMap[T] => {
return entity.entityType === type;
};

View file

@ -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;
}

View file

@ -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', () => {

View file

@ -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<InventoryEntity> = {
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,
};

View file

@ -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',
})}`
);

View file

@ -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<EntityGridStoriesArgs> = (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
),

View file

@ -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(() => {

View file

@ -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) {
</EuiFlexItem>
<EuiFlexItem className="eui-textTruncate">
<span className="eui-textTruncate" data-test-subj="entityNameDisplayName">
{entity[ENTITY_DISPLAY_NAME]}
{entity.entityDisplayName}
</span>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -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: <CustomHeaderCell title={alertsLabel} tooltipContent={alertsTooltip} />,
@ -91,21 +86,21 @@ export const getColumns = ({
]
: []),
{
id: ENTITY_DISPLAY_NAME,
id: 'entityDisplayName' as const,
// keep it for accessibility purposes
displayAsText: entityNameLabel,
display: <CustomHeaderCell title={entityNameLabel} tooltipContent={entityNameTooltip} />,
isSortable: true,
},
{
id: ENTITY_TYPE,
id: 'entityType' as const,
// keep it for accessibility purposes
displayAsText: entityTypeLabel,
display: <CustomHeaderCell title={entityTypeLabel} tooltipContent={entityTypeTooltip} />,
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[];
};

View file

@ -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 ? <AlertsBadge entity={entity} /> : null;
case ENTITY_TYPE:
case 'entityType':
return <BadgeFilterWithPopover field={ENTITY_TYPE} value={entityType} />;
case ENTITY_LAST_SEEN:
case 'entityLastSeenTimestamp':
return (
<FormattedMessage
id="xpack.inventory.entitiesGrid.euiDataGrid.lastSeen"
@ -105,7 +98,7 @@ export function EntitiesGrid({
values={{
date: (
<FormattedDate
value={entity[columnEntityTableId]}
value={entity.entityLastSeenTimestamp}
month="short"
day="numeric"
year="numeric"
@ -113,7 +106,7 @@ export function EntitiesGrid({
),
time: (
<FormattedTime
value={entity[columnEntityTableId]}
value={entity.entityLastSeenTimestamp}
hour12={false}
hour="2-digit"
minute="2-digit"
@ -123,19 +116,19 @@ export function EntitiesGrid({
}}
/>
);
case ENTITY_DISPLAY_NAME:
case 'entityDisplayName':
return <EntityName entity={entity} />;
case 'actions':
return (
discoverUrl && (
<EntityActions
discoverUrl={discoverUrl}
entityIdentifyingValue={entity[ENTITY_DISPLAY_NAME]}
entityIdentifyingValue={entity.entityDisplayName}
/>
)
);
default:
return entity[columnId as EntityColumnIds] || '';
return null;
}
},
[entities, getDiscoverRedirectUrl]

View file

@ -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<string, any> = {}) => ({
[ENTITY_LAST_SEEN]: generateRandomTimestamp(),
[ENTITY_TYPE]: entityType,
[ENTITY_DISPLAY_NAME]: faker.person.fullName(),
[ENTITY_ID]: generateId(),
...customFields,
const indentityFieldsPerType: Record<string, string[]> = {
host: ['host.name'],
container: ['container.id'],
service: ['service.name'],
};
const getEntityLatest = (
entityType: string,
overrides?: Partial<InventoryEntity>
): 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'),
alertsCount: 3,
},
{
...getEntity('host'),
alertsCount: 10,
},
{
...getEntity('host'),
const alertsMock: InventoryEntity[] = [
getEntityLatest('host', {
alertsCount: 1,
},
}),
getEntityLatest('service', {
alertsCount: 3,
}),
getEntityLatest('host', {
alertsCount: 10,
}),
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[];

View file

@ -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<CloudProvider, null>;
const getSingleValue = <T,>(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 (
<EuiFlexGroup
style={{ width: defaultIconSize, height: defaultIconSize }}
@ -44,7 +31,7 @@ export function EntityIcon({ entity }: EntityIconProps) {
>
<EuiFlexItem grow={false}>
<CloudProviderIcon
cloudProvider={cloudProvider}
cloudProvider={cloudProvider as CloudProvider | undefined}
size="m"
title={cloudProvider}
role="presentation"
@ -54,12 +41,11 @@ export function EntityIcon({ entity }: EntityIconProps) {
);
}
if (entityType === ENTITY_TYPES.SERVICE) {
const agentName = getSingleValue(entity[AGENT_NAME] as AgentName | AgentName[]);
return <AgentIcon agentName={agentName} role="presentation" />;
if (isBuiltinEntityOfType('service', entity)) {
return <AgentIcon agentName={castArray(entity.agent?.name)[0]} role="presentation" />;
}
if (entityType.startsWith('kubernetes')) {
if (entity.entityType.startsWith('kubernetes')) {
return <EuiIcon type="logoKubernetes" size="l" />;
}

View file

@ -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() {
<>
<InventorySummary totalEntities={value.entitiesCount} totalGroups={value.groups.length} />
<EuiSpacer size="m" />
{value.groups.map((group) => (
<InventoryGroupAccordion
key={`${value.groupBy}-${group[value.groupBy]}`}
groupBy={value.groupBy}
groupValue={group[value.groupBy]}
groupCount={group.count}
isLoading={loading}
/>
))}
{value.groups.map((group) => {
const groupValue = flattenObject(group)[value.groupBy];
return (
<InventoryGroupAccordion
key={`${value.groupBy}-${groupValue}`}
groupBy={value.groupBy}
groupValue={groupValue}
groupCount={group.count}
isLoading={loading}
/>
);
})}
</>
);
}

View file

@ -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> = {
[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<InventoryEntity> = {
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');

View file

@ -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<string, string> = {
@ -44,52 +41,38 @@ export const useDetailViewRedirect = () => {
const dashboardLocator = locators.get<DashboardLocatorParams>(DASHBOARD_APP_LOCATOR);
const serviceOverviewLocator = locators.get<ServiceOverviewParams>(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);
(entity: InventoryEntity) => {
const identityFieldsValue = entityManager.entityClient.getIdentityFieldsValue({
entity: {
identity_fields: entity.entityIdentityFields,
},
...entity,
});
const identityFields = castArray(entity.entityIdentityFields);
switch (type) {
case ENTITY_TYPES.HOST:
case ENTITY_TYPES.CONTAINER:
return assetDetailsLocator?.getRedirectUrl({
assetId: identityValue,
assetType: type,
});
case 'service':
return serviceOverviewLocator?.getRedirectUrl({
serviceName: identityValue,
// service.environemnt is not part of entity.identityFields
// we need to manually get its value
environment: [entity[SERVICE_ENVIRONMENT] || undefined].flat()[0],
});
default:
return undefined;
if (isBuiltinEntityOfType('host', entity) || isBuiltinEntityOfType('container', entity)) {
return assetDetailsLocator?.getRedirectUrl({
assetId: identityFieldsValue[identityFields[0]],
assetType: entity.entityType,
});
}
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]
);

View file

@ -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]
);

View file

@ -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<EntityGroup>('get_entities_groups', {
query,
filter: esQuery,
});
return esqlResultToPlainObjects<EntityGroup>(groups);
}

View file

@ -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<EntityInstance['entity'], 'type'>;
}>('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);
}

View file

@ -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<InventoryEntity> = {
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];

View file

@ -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<string, string[]>;
export const getIdentityFieldsPerEntityType = (entities: Entity[]) => {
const identityFieldsPerEntityType: IdentityFieldsPerEntityType = new Map();
export const getIdentityFieldsPerEntityType = (latestEntities: InventoryEntity[]) => {
const identityFieldsPerEntityType = new Map<string, string[]>();
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;

View file

@ -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<EntitySortableColumnIds, string> = {
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<InventoryEntity[]> {
// 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<EntityInstance>(
'get_latest_entities',
{
query,
filter: esQuery,
params,
}
);
return esqlResultToPlainObjects<Entity>(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,
};
});
}

View file

@ -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<Array<{ [key: string]: any; alertsCount?: number; [ENTITY_TYPE]: string }>> {
}): Promise<Array<{ [key: string]: any; alertsCount?: number; entityType: string }>> {
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,
}));
});

View file

@ -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,

View file

@ -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);

View file

@ -53,7 +53,6 @@
"@kbn/spaces-plugin",
"@kbn/cloud-plugin",
"@kbn/storybook",
"@kbn/zod",
"@kbn/dashboard-plugin",
"@kbn/deeplinks-analytics",
"@kbn/controls-plugin",