[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">


![redirect](https://github.com/user-attachments/assets/77d5d2e1-7ec4-40cd-b7d8-419e07e6b760)


### How to test
- While https://github.com/elastic/kibana/pull/196916 is not merged,
change `ENTITIES_LATEST_ALIAS` constant to `'.entities.v1.latest*'`
- Start a local kibana and es instances 
- Run ` node scripts/synthtrace k8s_entities.ts --live --clean `
- Run `PUT kbn:/internal/entities/managed/enablement` on the devtools
- Install the kubernetes integration package to have the dashboards
installed.
- Navigate to `Inventory` and click through the k8s entities

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2024-11-04 17:01:05 +01:00 committed by GitHub
parent b12e7d0e79
commit 8145cb7c6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 716 additions and 370 deletions

View file

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

View file

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

View file

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

View 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'
);
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,7 +26,6 @@ export async function getHasData({
});
const totalCount = esqlResultToPlainObjects(esqlResults)?.[0]._count ?? 0;
return { hasData: totalCount > 0 };
} catch (e) {
logger.error(e);

View file

@ -53,5 +53,8 @@
"@kbn/spaces-plugin",
"@kbn/cloud-plugin",
"@kbn/storybook",
"@kbn/zod",
"@kbn/dashboard-plugin",
"@kbn/deeplinks-analytics"
]
}

View file

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

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { EntityType } from './entity_types';
export { ENTITY_TYPES } from './entity_types';
export { EntityDataStreamType } from './entity_data_stream_types';

View file

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

View file

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