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