[8.x] [eem] _search endpoint / initial entity manager UI (#199609) (#202050)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[eem] _search endpoint / initial entity manager UI
(#199609)](https://github.com/elastic/kibana/pull/199609)

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

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

<!--BACKPORT [{"author":{"name":"Kevin
Lacabane","email":"kevin.lacabane@elastic.co"},"sourceCommit":{"committedDate":"2024-11-22T15:12:04Z","message":"[eem]
_search endpoint / initial entity manager UI (#199609)\n\n##
Summary\r\n\r\n- create `_search` endpoint to discover entities with
esql queries. It\r\ncurrently reads sources of the provided `type`
from\r\n`kibana_entity_definitions` index. Run this query to insert
a\r\ndefinition:\r\n```\r\nPOST kibana_entity_definitions/_doc\r\n{\r\n
\"entity_type\": \"service\",\r\n \"index_patterns\":
[\"remote_cluster:logs-*\"],\r\n \"metadata_fields\": [],\r\n
\"identity_fields\": [\"service.name\"],\r\n \"filters\": [],\r\n
\"timestamp_field\": \"@timestamp\"\r\n}\r\n```\r\n\r\nBy default
`_search` will look at data in the last 5m. The lookup period\r\ncan be
overriden by providing `start`/`end` parameters in ISO format.
It\r\nalso accepts a `limit` to specify the number of entities returned
which\r\ndefaults to 10\r\n\r\n```\r\nPOST
kbn:/internal/entities/v2/_search\r\n{\r\n \"type\": \"service\",\r\n
\"start\": \"2024-11-19T20:40:00.000Z\",\r\n \"end\":
\"2024-11-19T20:50:00.000Z\",\r\n \"limit\": 20\r\n}\r\n```\r\n\r\n-
create `_search/preview` endpoint to preview output of entity
sources\r\nwithout persisting them\r\n \r\n- create UI to preview
results of an entity definition at\r\n`/app/entity_manager`. The
application is living in its own plugin
at\r\n`observability_solution/entity_manager_app`\r\n![Screenshot
2024-11-11 at 11
37\r\n18](https://github.com/user-attachments/assets/f284342d-21a3-4ba1-be94-38cff311266c)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Milton Hultgren
<miltonhultgren@gmail.com>","sha":"0b3f4fbd3cd60663289fc13f8f01e3f4c9131479","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport
missing","v9.0.0","backport:prev-minor","ci:project-deploy-observability","Team:obs-entities"],"number":199609,"url":"https://github.com/elastic/kibana/pull/199609","mergeCommit":{"message":"[eem]
_search endpoint / initial entity manager UI (#199609)\n\n##
Summary\r\n\r\n- create `_search` endpoint to discover entities with
esql queries. It\r\ncurrently reads sources of the provided `type`
from\r\n`kibana_entity_definitions` index. Run this query to insert
a\r\ndefinition:\r\n```\r\nPOST kibana_entity_definitions/_doc\r\n{\r\n
\"entity_type\": \"service\",\r\n \"index_patterns\":
[\"remote_cluster:logs-*\"],\r\n \"metadata_fields\": [],\r\n
\"identity_fields\": [\"service.name\"],\r\n \"filters\": [],\r\n
\"timestamp_field\": \"@timestamp\"\r\n}\r\n```\r\n\r\nBy default
`_search` will look at data in the last 5m. The lookup period\r\ncan be
overriden by providing `start`/`end` parameters in ISO format.
It\r\nalso accepts a `limit` to specify the number of entities returned
which\r\ndefaults to 10\r\n\r\n```\r\nPOST
kbn:/internal/entities/v2/_search\r\n{\r\n \"type\": \"service\",\r\n
\"start\": \"2024-11-19T20:40:00.000Z\",\r\n \"end\":
\"2024-11-19T20:50:00.000Z\",\r\n \"limit\": 20\r\n}\r\n```\r\n\r\n-
create `_search/preview` endpoint to preview output of entity
sources\r\nwithout persisting them\r\n \r\n- create UI to preview
results of an entity definition at\r\n`/app/entity_manager`. The
application is living in its own plugin
at\r\n`observability_solution/entity_manager_app`\r\n![Screenshot
2024-11-11 at 11
37\r\n18](https://github.com/user-attachments/assets/f284342d-21a3-4ba1-be94-38cff311266c)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Milton Hultgren
<miltonhultgren@gmail.com>","sha":"0b3f4fbd3cd60663289fc13f8f01e3f4c9131479"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/199609","number":199609,"mergeCommit":{"message":"[eem]
_search endpoint / initial entity manager UI (#199609)\n\n##
Summary\r\n\r\n- create `_search` endpoint to discover entities with
esql queries. It\r\ncurrently reads sources of the provided `type`
from\r\n`kibana_entity_definitions` index. Run this query to insert
a\r\ndefinition:\r\n```\r\nPOST kibana_entity_definitions/_doc\r\n{\r\n
\"entity_type\": \"service\",\r\n \"index_patterns\":
[\"remote_cluster:logs-*\"],\r\n \"metadata_fields\": [],\r\n
\"identity_fields\": [\"service.name\"],\r\n \"filters\": [],\r\n
\"timestamp_field\": \"@timestamp\"\r\n}\r\n```\r\n\r\nBy default
`_search` will look at data in the last 5m. The lookup period\r\ncan be
overriden by providing `start`/`end` parameters in ISO format.
It\r\nalso accepts a `limit` to specify the number of entities returned
which\r\ndefaults to 10\r\n\r\n```\r\nPOST
kbn:/internal/entities/v2/_search\r\n{\r\n \"type\": \"service\",\r\n
\"start\": \"2024-11-19T20:40:00.000Z\",\r\n \"end\":
\"2024-11-19T20:50:00.000Z\",\r\n \"limit\": 20\r\n}\r\n```\r\n\r\n-
create `_search/preview` endpoint to preview output of entity
sources\r\nwithout persisting them\r\n \r\n- create UI to preview
results of an entity definition at\r\n`/app/entity_manager`. The
application is living in its own plugin
at\r\n`observability_solution/entity_manager_app`\r\n![Screenshot
2024-11-11 at 11
37\r\n18](https://github.com/user-attachments/assets/f284342d-21a3-4ba1-be94-38cff311266c)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Milton Hultgren
<miltonhultgren@gmail.com>","sha":"0b3f4fbd3cd60663289fc13f8f01e3f4c9131479"}}]}]
BACKPORT-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kevin Lacabane 2024-11-28 11:14:38 +01:00 committed by GitHub
parent eb0abce6fe
commit 337ab20ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1482 additions and 14 deletions

1
.github/CODEOWNERS vendored
View file

@ -415,6 +415,7 @@ x-pack/plugins/enterprise_search @elastic/search-kibana
x-pack/plugins/observability_solution/entities_data_access @elastic/obs-entities
x-pack/packages/kbn-entities-schema @elastic/obs-entities
x-pack/test/api_integration/apis/entity_manager/fixture_plugin @elastic/obs-entities
x-pack/plugins/observability_solution/entity_manager_app @elastic/obs-entities
x-pack/plugins/entity_manager @elastic/obs-entities
examples/error_boundary @elastic/appex-sharedux
packages/kbn-es @elastic/kibana-operations

View file

@ -579,6 +579,10 @@ security and spaces filtering.
|This plugin provides access to observed entity data, such as information about hosts, pods, containers, services, and more.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/entity_manager_app/README.md[entityManagerApp]
|This plugin provides a user interface to interact with the Entity Manager.
|{kib-repo}blob/{branch}/x-pack/plugins/event_log/README.md[eventLog]
|The event log plugin provides a persistent history of alerting and action
activities.

View file

@ -478,6 +478,7 @@
"@kbn/entities-data-access-plugin": "link:x-pack/plugins/observability_solution/entities_data_access",
"@kbn/entities-schema": "link:x-pack/packages/kbn-entities-schema",
"@kbn/entity-manager-fixture-plugin": "link:x-pack/test/api_integration/apis/entity_manager/fixture_plugin",
"@kbn/entityManager-app-plugin": "link:x-pack/plugins/observability_solution/entity_manager_app",
"@kbn/entityManager-plugin": "link:x-pack/plugins/entity_manager",
"@kbn/error-boundary-example-plugin": "link:examples/error_boundary",
"@kbn/es-errors": "link:packages/kbn-es-errors",

View file

@ -42,6 +42,7 @@ pageLoadAssetSize:
embeddableEnhanced: 22107
enterpriseSearch: 66810
entityManager: 17175
entityManagerApp: 20378
esql: 37000
esqlDataGrid: 24582
esUiShared: 326654

View file

@ -143,6 +143,7 @@ export const applicationUsageSchema = {
enterpriseSearchSemanticSearch: commonSchema,
enterpriseSearchVectorSearch: commonSchema,
enterpriseSearchElasticsearch: commonSchema,
entity_manager: commonSchema,
appSearch: commonSchema,
workplaceSearch: commonSchema,
searchExperiences: commonSchema,

View file

@ -3015,6 +3015,137 @@
}
}
},
"entity_manager": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"appSearch": {
"properties": {
"appId": {

View file

@ -824,6 +824,8 @@
"@kbn/entities-schema/*": ["x-pack/packages/kbn-entities-schema/*"],
"@kbn/entity-manager-fixture-plugin": ["x-pack/test/api_integration/apis/entity_manager/fixture_plugin"],
"@kbn/entity-manager-fixture-plugin/*": ["x-pack/test/api_integration/apis/entity_manager/fixture_plugin/*"],
"@kbn/entityManager-app-plugin": ["x-pack/plugins/observability_solution/entity_manager_app"],
"@kbn/entityManager-app-plugin/*": ["x-pack/plugins/observability_solution/entity_manager_app/*"],
"@kbn/entityManager-plugin": ["x-pack/plugins/entity_manager"],
"@kbn/entityManager-plugin/*": ["x-pack/plugins/entity_manager/*"],
"@kbn/error-boundary-example-plugin": ["examples/error_boundary"],

View file

@ -23,6 +23,13 @@ export interface MetadataRecord {
[key: string]: string[] | MetadataRecord | string;
}
export interface EntityV2 {
'entity.id': string;
'entity.last_seen_timestamp': string;
'entity.type': string;
[metadata: string]: any;
}
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
type Literal = z.infer<typeof literalSchema>;

View file

@ -8,9 +8,13 @@
"plugin": {
"id": "entityManager",
"configPath": ["xpack", "entityManager"],
"requiredPlugins": ["security", "encryptedSavedObjects", "licensing"],
"browser": true,
"server": true,
"requiredPlugins": [
"security",
"encryptedSavedObjects",
"licensing"
],
"requiredBundles": []
}
}

View file

@ -16,6 +16,8 @@ export const plugin: PluginInitializer<
return new Plugin(context);
};
export { EntityClient } from './lib/entity_client';
export type { EntityManagerPublicPluginSetup, EntityManagerPublicPluginStart };
export type EntityManagerAppId = 'entityManager';

View file

@ -22,16 +22,14 @@ export class Plugin implements EntityManagerPluginClass {
}
setup(core: CoreSetup) {
const entityClient = new EntityClient(core);
return {
entityClient,
entityClient: new EntityClient(core),
};
}
start(core: CoreStart) {
const entityClient = new EntityClient(core);
return {
entityClient,
entityClient: new EntityClient(core),
};
}

View file

@ -10,7 +10,6 @@ import type { EntityClient } from './lib/entity_client';
export interface EntityManagerPublicPluginSetup {
entityClient: EntityClient;
}
export interface EntityManagerPublicPluginStart {
entityClient: EntityClient;
}

View file

@ -5,10 +5,9 @@
* 2.0.
*/
export class AssetNotFoundError extends Error {
constructor(ean: string) {
super(`Asset with ean (${ean}) not found in the provided time range`);
Object.setPrototypeOf(this, new.target.prototype);
this.name = 'AssetNotFoundError';
export class UnknownEntityType extends Error {
constructor(message: string) {
super(message);
this.name = 'UnknownEntityType';
}
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EntityDefinition, EntityDefinitionUpdate } from '@kbn/entities-schema';
import { EntityV2, EntityDefinition, EntityDefinitionUpdate } from '@kbn/entities-schema';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { Logger } from '@kbn/logging';
@ -23,6 +23,9 @@ import { stopTransforms } from './entities/stop_transforms';
import { deleteIndices } from './entities/delete_index';
import { EntityDefinitionWithState } from './entities/types';
import { EntityDefinitionUpdateConflict } from './entities/errors/entity_definition_update_conflict';
import { EntitySource, getEntityInstancesQuery } from './queries';
import { mergeEntitiesList, runESQLQuery } from './queries/utils';
import { UnknownEntityType } from './entities/errors/unknown_entity_type';
export class EntityClient {
constructor(
@ -126,8 +129,6 @@ export class EntityClient {
});
if (deleteData) {
// delete data with current user as system user does not have
// .entities privileges
await deleteIndices(this.options.esClient, definition, this.options.logger);
}
}
@ -170,4 +171,114 @@ export class EntityClient {
this.options.logger.info(`Stopping transforms for definition [${definition.id}]`);
return stopTransforms(this.options.esClient, definition, this.options.logger);
}
async getEntitySources({ type }: { type: string }) {
const result = await this.options.esClient.search<EntitySource>({
index: 'kibana_entity_definitions',
query: {
bool: {
must: {
term: { entity_type: type },
},
},
},
});
return result.hits.hits.map((hit) => hit._source) as EntitySource[];
}
async searchEntities({
type,
start,
end,
metadataFields = [],
filters = [],
limit = 10,
}: {
type: string;
start: string;
end: string;
metadataFields?: string[];
filters?: string[];
limit?: number;
}) {
const sources = await this.getEntitySources({ type });
if (sources.length === 0) {
throw new UnknownEntityType(`No sources found for entity type [${type}]`);
}
return this.searchEntitiesBySources({
sources,
start,
end,
metadataFields,
filters,
limit,
});
}
async searchEntitiesBySources({
sources,
start,
end,
metadataFields = [],
filters = [],
limit = 10,
}: {
sources: EntitySource[];
start: string;
end: string;
metadataFields?: string[];
filters?: string[];
limit?: number;
}) {
const entities = await Promise.all(
sources.map(async (source) => {
const mandatoryFields = [source.timestamp_field, ...source.identity_fields];
const metaFields = [...metadataFields, ...source.metadata_fields];
const { fields } = await this.options.esClient.fieldCaps({
index: source.index_patterns,
fields: [...mandatoryFields, ...metaFields],
});
const sourceHasMandatoryFields = mandatoryFields.every((field) => !!fields[field]);
if (!sourceHasMandatoryFields) {
// we can't build entities without id fields so we ignore the source.
// filters should likely behave similarly.
this.options.logger.info(
`Ignoring source for type [${source.type}] with index_patterns [${source.index_patterns}] because some mandatory fields [${mandatoryFields}] are not mapped`
);
return [];
}
// but metadata field not being available is fine
const availableMetadataFields = metaFields.filter((field) => fields[field]);
const query = getEntityInstancesQuery({
source: {
...source,
metadata_fields: availableMetadataFields,
filters: [...source.filters, ...filters],
},
start,
end,
limit,
});
this.options.logger.debug(`Entity query: ${query}`);
const rawEntities = await runESQLQuery<EntityV2>({
query,
esClient: this.options.esClient,
});
return rawEntities.map((entity) => {
entity['entity.id'] = source.identity_fields.map((field) => entity[field]).join(':');
entity['entity.type'] = source.type;
return entity;
});
})
).then((results) => results.flat());
return mergeEntitiesList(entities).slice(0, limit);
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 { getEntityInstancesQuery } from '.';
describe('getEntityInstancesQuery', () => {
describe('getEntityInstancesQuery', () => {
it('generates a valid esql query', () => {
const query = getEntityInstancesQuery({
source: {
type: 'service',
index_patterns: ['logs-*', 'metrics-*'],
identity_fields: ['service.name'],
metadata_fields: ['host.name'],
filters: [],
timestamp_field: 'custom_timestamp_field',
},
limit: 5,
start: '2024-11-20T19:00:00.000Z',
end: '2024-11-20T20:00:00.000Z',
});
expect(query).toEqual(
'FROM logs-*,metrics-*|' +
'WHERE custom_timestamp_field >= "2024-11-20T19:00:00.000Z"|' +
'WHERE custom_timestamp_field <= "2024-11-20T20:00:00.000Z"|' +
'WHERE service.name IS NOT NULL|' +
'STATS entity.last_seen_timestamp=MAX(custom_timestamp_field),metadata.host.name=VALUES(host.name) BY service.name|' +
'SORT entity.last_seen_timestamp DESC|' +
'LIMIT 5'
);
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 { z } from '@kbn/zod';
export const entitySourceSchema = z.object({
type: z.string(),
timestamp_field: z.optional(z.string()).default('@timestamp'),
index_patterns: z.array(z.string()),
identity_fields: z.array(z.string()),
metadata_fields: z.array(z.string()),
filters: z.array(z.string()),
});
export type EntitySource = z.infer<typeof entitySourceSchema>;
const sourceCommand = ({ source }: { source: EntitySource }) => {
let query = `FROM ${source.index_patterns}`;
const esMetadataFields = source.metadata_fields.filter((field) =>
['_index', '_id'].includes(field)
);
if (esMetadataFields.length) {
query += ` METADATA ${esMetadataFields.join(',')}`;
}
return query;
};
const filterCommands = ({
source,
start,
end,
}: {
source: EntitySource;
start: string;
end: string;
}) => {
const commands = [
`WHERE ${source.timestamp_field} >= "${start}"`,
`WHERE ${source.timestamp_field} <= "${end}"`,
];
source.identity_fields.forEach((field) => {
commands.push(`WHERE ${field} IS NOT NULL`);
});
source.filters.forEach((filter) => {
commands.push(`WHERE ${filter}`);
});
return commands;
};
const statsCommand = ({ source }: { source: EntitySource }) => {
const aggs = [
// default 'last_seen' attribute
`entity.last_seen_timestamp=MAX(${source.timestamp_field})`,
...source.metadata_fields
.filter((field) => !source.identity_fields.some((idField) => idField === field))
.map((field) => `metadata.${field}=VALUES(${field})`),
];
return `STATS ${aggs.join(',')} BY ${source.identity_fields.join(',')}`;
};
export function getEntityInstancesQuery({
source,
limit,
start,
end,
}: {
source: EntitySource;
limit: number;
start: string;
end: string;
}): string {
const commands = [
sourceCommand({ source }),
...filterCommands({ source, start, end }),
statsCommand({ source }),
`SORT entity.last_seen_timestamp DESC`,
`LIMIT ${limit}`,
];
return commands.join('|');
}

View file

@ -0,0 +1,135 @@
/*
* 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 { mergeEntitiesList } from './utils';
describe('mergeEntitiesList', () => {
describe('mergeEntitiesList', () => {
it('merges entities on entity.id', () => {
const entities = [
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
},
];
const mergedEntities = mergeEntitiesList(entities);
expect(mergedEntities.length).toEqual(1);
expect(mergedEntities[0]).toEqual({
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
});
});
it('merges metadata fields', () => {
const entities = [
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-1',
'metadata.agent.name': 'agent-1',
'metadata.service.environment': ['dev', 'staging'],
'metadata.only_in_record_1': 'foo',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': ['host-2', 'host-3'],
'metadata.agent.name': 'agent-2',
'metadata.service.environment': 'prod',
'metadata.only_in_record_2': 'bar',
},
];
const mergedEntities = mergeEntitiesList(entities);
expect(mergedEntities.length).toEqual(1);
expect(mergedEntities[0]).toEqual({
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': ['host-1', 'host-2', 'host-3'],
'metadata.agent.name': ['agent-1', 'agent-2'],
'metadata.service.environment': ['dev', 'staging', 'prod'],
'metadata.only_in_record_1': 'foo',
'metadata.only_in_record_2': 'bar',
});
});
it('picks most recent timestamp when merging', () => {
const entities = [
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-1',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T20:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-2',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T16:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-3',
},
];
const mergedEntities = mergeEntitiesList(entities);
expect(mergedEntities.length).toEqual(1);
expect(mergedEntities[0]).toEqual({
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T20:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': ['host-1', 'host-2', 'host-3'],
});
});
it('deduplicates metadata values', () => {
const entities = [
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T18:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-1',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T20:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': 'host-2',
},
{
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T16:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': ['host-1', 'host-2'],
},
];
const mergedEntities = mergeEntitiesList(entities);
expect(mergedEntities.length).toEqual(1);
expect(mergedEntities[0]).toEqual({
'entity.id': 'foo',
'entity.last_seen_timestamp': '2024-11-20T20:00:00.000Z',
'entity.type': 'service',
'metadata.host.name': ['host-1', 'host-2'],
});
});
});
});

View file

@ -0,0 +1,90 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { EntityV2 } from '@kbn/entities-schema';
import { ESQLSearchResponse } from '@kbn/es-types';
import { uniq } from 'lodash';
function mergeEntities(entity1: EntityV2, entity2: EntityV2): EntityV2 {
const merged: EntityV2 = {
...entity1,
'entity.last_seen_timestamp': new Date(
Math.max(
Date.parse(entity1['entity.last_seen_timestamp']),
Date.parse(entity2['entity.last_seen_timestamp'])
)
).toISOString(),
};
for (const [key, value] of Object.entries(entity2).filter(([_key]) =>
_key.startsWith('metadata.')
)) {
if (merged[key]) {
merged[key] = uniq([
...(Array.isArray(merged[key]) ? merged[key] : [merged[key]]),
...(Array.isArray(value) ? value : [value]),
]);
} else {
merged[key] = value;
}
}
return merged;
}
export function mergeEntitiesList(entities: EntityV2[]): EntityV2[] {
const instances: { [key: string]: EntityV2 } = {};
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
const id = entity['entity.id'];
if (instances[id]) {
instances[id] = mergeEntities(instances[id], entity);
} else {
instances[id] = entity;
}
}
return Object.values(instances);
}
export async function runESQLQuery<T>({
esClient,
query,
}: {
esClient: ElasticsearchClient;
query: string;
}): Promise<T[]> {
const esqlResponse = (await esClient.esql.query(
{
query,
format: 'json',
},
{ querystring: { drop_null_columns: true } }
)) as unknown as ESQLSearchResponse;
const documents = esqlResponse.values.map((row) =>
row.reduce<Record<string, any>>((acc, value, index) => {
const column = esqlResponse.columns[index];
if (!column) {
return acc;
}
// Removes the type suffix from the column name
const name = column.name.replace(/\.(text|keyword)$/, '');
if (!acc[name]) {
acc[name] = value;
}
return acc;
}, {})
) as T[];
return documents;
}

View file

@ -10,6 +10,7 @@ import { deleteEntityDefinitionRoute } from './delete';
import { getEntityDefinitionRoute } from './get';
import { resetEntityDefinitionRoute } from './reset';
import { updateEntityDefinitionRoute } from './update';
import { searchEntitiesRoute, searchEntitiesPreviewRoute } from '../v2/search';
export const entitiesRoutes = {
...createEntityDefinitionRoute,
@ -17,4 +18,6 @@ export const entitiesRoutes = {
...getEntityDefinitionRoute,
...resetEntityDefinitionRoute,
...updateEntityDefinitionRoute,
...searchEntitiesRoute,
...searchEntitiesPreviewRoute,
};

View file

@ -0,0 +1,96 @@
/*
* 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 moment from 'moment';
import { z } from '@kbn/zod';
import { createEntityManagerServerRoute } from '../create_entity_manager_server_route';
import { entitySourceSchema } from '../../lib/queries';
import { UnknownEntityType } from '../../lib/entities/errors/unknown_entity_type';
export const searchEntitiesRoute = createEntityManagerServerRoute({
endpoint: 'POST /internal/entities/v2/_search',
params: z.object({
body: z.object({
type: z.string(),
metadata_fields: z.optional(z.array(z.string())).default([]),
filters: z.optional(z.array(z.string())).default([]),
start: z
.optional(z.string())
.default(() => moment().subtract(5, 'minutes').toISOString())
.refine((val) => moment(val).isValid(), {
message: 'start should be a date in ISO format',
}),
end: z
.optional(z.string())
.default(() => moment().toISOString())
.refine((val) => moment(val).isValid(), {
message: 'start should be a date in ISO format',
}),
limit: z.optional(z.number()).default(10),
}),
}),
handler: async ({ request, response, params, logger, getScopedClient }) => {
try {
const { type, start, end, limit, filters, metadata_fields: metadataFields } = params.body;
const client = await getScopedClient({ request });
const entities = await client.searchEntities({
type,
filters,
metadataFields,
start,
end,
limit,
});
return response.ok({ body: { entities } });
} catch (e) {
logger.error(e);
if (e instanceof UnknownEntityType) {
return response.notFound({ body: e });
}
return response.customError({ body: e, statusCode: 500 });
}
},
});
export const searchEntitiesPreviewRoute = createEntityManagerServerRoute({
endpoint: 'POST /internal/entities/v2/_search/preview',
params: z.object({
body: z.object({
sources: z.array(entitySourceSchema),
start: z
.optional(z.string())
.default(() => moment().subtract(5, 'minutes').toISOString())
.refine((val) => moment(val).isValid(), {
message: 'start should be a date in ISO format',
}),
end: z
.optional(z.string())
.default(() => moment().toISOString())
.refine((val) => moment(val).isValid(), {
message: 'start should be a date in ISO format',
}),
limit: z.optional(z.number()).default(10),
}),
}),
handler: async ({ request, response, params, logger, getScopedClient }) => {
const { sources, start, end, limit } = params.body;
const client = await getScopedClient({ request });
const entities = await client.searchEntitiesBySources({
sources,
start,
end,
limit,
});
return response.ok({ body: { entities } });
},
});

View file

@ -35,5 +35,6 @@
"@kbn/encrypted-saved-objects-plugin",
"@kbn/licensing-plugin",
"@kbn/core-saved-objects-server",
"@kbn/es-types",
]
}

View file

@ -0,0 +1,3 @@
# Entity Manager App Plugin
This plugin provides a user interface to interact with the Entity Manager.

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
const path = require('path');
module.exports = {
preset: '@kbn/test',
rootDir: path.resolve(__dirname, '../../../..'),
roots: ['<rootDir>/x-pack/plugins/observability_solution/entity_manager_app'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/observability_solution/entity_manager_app',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/observability_solution/entity_manager_app/{common,public,server}/**/*.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,29 @@
{
"type": "plugin",
"id": "@kbn/entityManager-app-plugin",
"owner": "@elastic/obs-entities",
"group": "observability",
"visibility": "private",
"description": "Entity manager plugin for entity assets (inventory, topology, etc)",
"plugin": {
"id": "entityManagerApp",
"configPath": ["xpack", "entityManagerApp"],
"browser": true,
"server": false,
"requiredPlugins": [
"entityManager",
"observabilityShared",
"presentationUtil",
"usageCollection",
"licensing"
],
"optionalPlugins": [
"cloud",
"serverless"
],
"requiredBundles": [
"kibanaReact",
"kibanaUtils"
]
}
}

View file

@ -0,0 +1,103 @@
/*
* 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 { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public';
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { EntityClient } from '@kbn/entityManager-plugin/public';
import React from 'react';
import ReactDOM from 'react-dom';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { Router } from '@kbn/shared-ux-router';
import { PluginContext } from './context/plugin_context';
import { EntityManagerPluginStart } from './types';
import { EntityManagerOverviewPage } from './pages/overview';
export function renderApp({
core,
plugins,
appMountParameters,
ObservabilityPageTemplate,
usageCollection,
isDev,
kibanaVersion,
isServerless,
entityClient,
}: {
core: CoreStart;
plugins: EntityManagerPluginStart;
appMountParameters: AppMountParameters;
ObservabilityPageTemplate: React.ComponentType<LazyObservabilityPageTemplateProps>;
usageCollection: UsageCollectionSetup;
isDev?: boolean;
kibanaVersion: string;
isServerless?: boolean;
entityClient: EntityClient;
}) {
const { element, history, theme$ } = appMountParameters;
const isDarkMode = core.theme.getTheme().darkMode;
// ensure all divs are .kbnAppWrappers
element.classList.add(APP_WRAPPER_CLASS);
const ApplicationUsageTrackingProvider =
usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
const CloudProvider = plugins.cloud?.CloudContextProvider ?? React.Fragment;
ReactDOM.render(
<KibanaRenderContextProvider {...core}>
<ApplicationUsageTrackingProvider>
<KibanaThemeProvider {...{ theme: { theme$ } }}>
<CloudProvider>
<KibanaContextProvider
services={{
...core,
...plugins,
storage: new Storage(localStorage),
entityClient: new EntityClient(core),
isDev,
kibanaVersion,
isServerless,
}}
>
<PluginContext.Provider
value={{
isDev,
isServerless,
appMountParameters,
ObservabilityPageTemplate,
entityClient,
}}
>
<Router history={history}>
<EuiThemeProvider darkMode={isDarkMode}>
<RedirectAppLinks coreStart={core} data-test-subj="observabilityMainContainer">
<PerformanceContextProvider>
<EntityManagerOverviewPage />
</PerformanceContextProvider>
</RedirectAppLinks>
</EuiThemeProvider>
</Router>
</PluginContext.Provider>
</KibanaContextProvider>
</CloudProvider>
</KibanaThemeProvider>
</ApplicationUsageTrackingProvider>
</KibanaRenderContextProvider>,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
}

View file

@ -0,0 +1,21 @@
/*
* 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 { createContext } from 'react';
import type { AppMountParameters } from '@kbn/core/public';
import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public';
import { EntityClient } from '@kbn/entityManager-plugin/public';
export interface PluginContextValue {
isDev?: boolean;
isServerless?: boolean;
appMountParameters?: AppMountParameters;
ObservabilityPageTemplate: React.ComponentType<LazyObservabilityPageTemplateProps>;
entityClient: EntityClient;
}
export const PluginContext = createContext<PluginContextValue | null>(null);

View file

@ -0,0 +1,22 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { EntityClient } from '@kbn/entityManager-plugin/public';
export type StartServices<AdditionalServices extends object = {}> = CoreStart &
AdditionalServices & {
storage: Storage;
kibanaVersion: string;
entityClient: EntityClient;
};
const useTypedKibana = <AdditionalServices extends object = {}>() =>
useKibana<StartServices<AdditionalServices>>();
export { useTypedKibana as useKibana };

View file

@ -0,0 +1,19 @@
/*
* 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 { useContext } from 'react';
import { PluginContext } from '../context/plugin_context';
import type { PluginContextValue } from '../context/plugin_context';
export function usePluginContext(): PluginContextValue {
const context = useContext(PluginContext);
if (!context) {
throw new Error('Plugin context value is missing!');
}
return context;
}

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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import { Plugin } from './plugin';
export const plugin: PluginInitializer<{}, {}> = (context: PluginInitializerContext) => {
return new Plugin(context);
};

View file

@ -0,0 +1,347 @@
/*
* 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 React, { useState } from 'react';
import { v4 as uuid } from 'uuid';
import {
EuiBasicTable,
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiHorizontalRule,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { EntityV2 } from '@kbn/entities-schema';
import { usePluginContext } from '../../hooks/use_plugin_context';
function EntitySourceForm({
source,
index,
onFieldChange,
}: {
source: any;
index: number;
onFieldChange: Function;
}) {
const onArrayFieldChange =
(field: Exclude<keyof EntitySource, 'id'>) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
if (!value) {
onFieldChange(index, field, []);
} else {
onFieldChange(index, field, e.target.value.trim().split(','));
}
};
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow label="Index patterns (comma-separated)">
<EuiFieldText
data-test-subj="entityManagerFormIndexPatterns"
name="index_patterns"
defaultValue={source.index_patterns.join(',')}
isInvalid={source.index_patterns.length === 0}
onChange={onArrayFieldChange('index_patterns')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Identify fields (comma-separated field names)">
<EuiFieldText
data-test-subj="entityManagerFormIdentityFields"
name="identity_fields"
defaultValue={source.identity_fields.join(',')}
isInvalid={source.identity_fields.length === 0}
onChange={onArrayFieldChange('identity_fields')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Filters (comma-separated ESQL filters)">
<EuiFieldText
data-test-subj="entityManagerFormFilters"
name="filters"
defaultValue={source.filters.join(',')}
onChange={onArrayFieldChange('filters')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Metadata (comma-separated field names)">
<EuiFieldText
data-test-subj="entityManagerFormMetadata"
name="metadata"
defaultValue={source.metadata_fields.join(',')}
onChange={onArrayFieldChange('metadata_fields')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Timestamp field">
<EuiFieldText
data-test-subj="entityManagerFormTimestamp"
name="timestamp_field"
defaultValue={source.timestamp_field}
onChange={(e) => onFieldChange(index, 'timestamp_field', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
}
interface EntitySource {
id: string;
index_patterns?: string[];
identity_fields?: string[];
metadata_fields?: string[];
filters?: string[];
timestamp_field?: string;
}
const newEntitySource = ({
indexPatterns = [],
identityFields = [],
metadataFields = [],
filters = [],
timestampField = '@timestamp',
}: {
indexPatterns?: string[];
identityFields?: string[];
metadataFields?: string[];
filters?: string[];
timestampField?: string;
}) => ({
id: uuid(),
index_patterns: indexPatterns,
identity_fields: identityFields,
metadata_fields: metadataFields,
timestamp_field: timestampField,
filters,
});
export function EntityManagerOverviewPage() {
const { ObservabilityPageTemplate, entityClient } = usePluginContext();
const [previewEntities, setPreviewEntities] = useState<EntityV2[]>([]);
const [isSearchingEntities, setIsSearchingEntities] = useState(false);
const [previewError, setPreviewError] = useState(null);
const [formErrors, setFormErrors] = useState<string[]>([]);
const [entityType, setEntityType] = useState('service');
const [entitySources, setEntitySources] = useState([
newEntitySource({
indexPatterns: ['remote_cluster:logs-*'],
identityFields: ['service.name'],
}),
]);
const searchEntities = async () => {
if (
!entitySources.some(
(source) => source.identity_fields.length > 0 && source.index_patterns.length > 0
)
) {
setFormErrors(['No valid source found']);
return;
}
setIsSearchingEntities(true);
setFormErrors([]);
setPreviewError(null);
try {
const { entities } = await entityClient.repositoryClient(
'POST /internal/entities/v2/_search/preview',
{
params: {
body: {
sources: entitySources
.filter(
(source) => source.index_patterns.length > 0 && source.identity_fields.length > 0
)
.map((source) => ({ ...source, type: entityType })),
},
},
}
);
setPreviewEntities(entities);
} catch (err) {
setPreviewError(err.body?.message);
} finally {
setIsSearchingEntities(false);
}
};
return (
<ObservabilityPageTemplate
data-test-subj="entitiesPage"
pageHeader={{
bottomBorder: true,
pageTitle: 'Entity Manager',
}}
>
<EuiForm component="form" isInvalid={formErrors.length > 0} error={formErrors}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiTitle size="s">
<h2>Entity type</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow>
<EuiFieldText
data-test-subj="entityManagerFormType"
name="type"
defaultValue={entityType}
placeholder="host, service, user..."
onChange={(e) => {
setEntityType(e.target.value.trim());
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2>Entity sources</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="entityManagerFormAddSource"
iconType="plusInCircle"
onClick={() => setEntitySources([...entitySources, newEntitySource({})])}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{entitySources.map((source, i) => (
<div key={source.id}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h4>Source {i + 1}</h4>
</EuiTitle>
</EuiFlexItem>
{entitySources.length > 1 ? (
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="entityManagerFormRemoveSource"
color={'danger'}
iconType={'minusInCircle'}
onClick={() => {
entitySources.splice(i, 1);
setEntitySources(entitySources.map((_source) => ({ ..._source })));
}}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiSpacer size="s" />
<EntitySourceForm
source={source}
index={i}
onFieldChange={(
index: number,
field: Exclude<keyof EntitySource, 'id'>,
value: any
) => {
entitySources[index][field] = value;
setEntitySources([...entitySources]);
}}
/>
{i === entitySources.length - 1 ? (
<EuiSpacer size="m" />
) : (
<EuiHorizontalRule margin="m" />
)}
</div>
))}
<EuiFormRow>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton
data-test-subj="entityManagerFormPreview"
isDisabled={isSearchingEntities}
onClick={searchEntities}
>
Preview
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton data-test-subj="entityManagerFormCreate" isDisabled={true} color="primary">
Create
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiForm>
<EuiSpacer size="s" />
{previewError ? (
<EuiCallOut title="Error previewing entity definition" color="danger" iconType="error">
<p>{previewError}</p>
</EuiCallOut>
) : null}
<EuiBasicTable
loading={isSearchingEntities}
tableCaption={'Preview entities'}
items={previewEntities}
columns={[
{
field: 'entity.id',
name: 'entity.id',
},
{
field: 'entity.type',
name: 'entity.type',
},
{
field: 'entity.last_seen_timestamp',
name: 'entity.last_seen_timestamp',
},
...Array.from(new Set(entitySources.flatMap((source) => source.identity_fields))).map(
(field) => ({
field,
name: field,
})
),
...Array.from(new Set(entitySources.flatMap((source) => source.metadata_fields))).map(
(field) => ({
field: `metadata.${field}`,
name: `metadata.${field}`,
})
),
]}
/>
</ObservabilityPageTemplate>
);
}

View file

@ -0,0 +1,80 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import {
App,
AppMountParameters,
AppStatus,
AppUpdater,
CoreSetup,
DEFAULT_APP_CATEGORIES,
PluginInitializerContext,
} from '@kbn/core/public';
import { Logger } from '@kbn/logging';
import { EntityClient } from '@kbn/entityManager-plugin/public';
import {
EntityManagerAppPluginClass,
EntityManagerPluginStart,
EntityManagerPluginSetup,
} from './types';
export class Plugin implements EntityManagerAppPluginClass {
public logger: Logger;
private readonly appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
constructor(private readonly context: PluginInitializerContext<{}>) {
this.logger = context.logger.get();
}
setup(core: CoreSetup<EntityManagerPluginStart, {}>, pluginSetup: EntityManagerPluginSetup) {
const kibanaVersion = this.context.env.packageInfo.version;
const mount = async (params: AppMountParameters<unknown>) => {
const { renderApp } = await import('./application');
const [coreStart, pluginsStart] = await core.getStartServices();
return renderApp({
appMountParameters: params,
core: coreStart,
isDev: this.context.env.mode.dev,
kibanaVersion,
usageCollection: pluginSetup.usageCollection,
ObservabilityPageTemplate: pluginsStart.observabilityShared.navigation.PageTemplate,
plugins: pluginsStart,
isServerless: !!pluginsStart.serverless,
entityClient: new EntityClient(core),
});
};
const appUpdater$ = this.appUpdater$;
const app: App = {
id: 'entity_manager',
title: 'Entity Manager',
order: 8002,
updater$: appUpdater$,
euiIconType: 'logoObservability',
appRoute: '/app/entity_manager',
category: DEFAULT_APP_CATEGORIES.observability,
mount,
visibleIn: [],
keywords: ['observability', 'monitor', 'entities'],
status: AppStatus.inaccessible,
};
core.application.register(app);
return {};
}
start() {
return {};
}
stop() {}
}

View file

@ -0,0 +1,27 @@
/*
* 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 React from 'react';
import { EntityManagerOverviewPage } from './pages/overview';
interface RouteDef {
[key: string]: {
handler: () => React.ReactElement;
params: Record<string, string>;
exact: boolean;
};
}
export function getRoutes(): RouteDef {
return {
'/app/entity_manager': {
handler: () => <EntityManagerOverviewPage />,
params: {},
exact: true,
},
};
}

View file

@ -0,0 +1,32 @@
/*
* 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 { Plugin as PluginClass } from '@kbn/core/public';
import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import { CloudStart } from '@kbn/cloud-plugin/public';
import { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
import {
ObservabilitySharedPluginSetup,
ObservabilitySharedPluginStart,
} from '@kbn/observability-shared-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { EntityManagerPublicPluginSetup } from '@kbn/entityManager-plugin/public/types';
export interface EntityManagerPluginSetup {
observabilityShared: ObservabilitySharedPluginSetup;
serverless?: ServerlessPluginSetup;
usageCollection: UsageCollectionSetup;
entityManager: EntityManagerPublicPluginSetup;
}
export interface EntityManagerPluginStart {
presentationUtil: PresentationUtilPluginStart;
cloud?: CloudStart;
serverless?: ServerlessPluginStart;
observabilityShared: ObservabilitySharedPluginStart;
}
export type EntityManagerAppPluginClass = PluginClass<{}, {}>;

View file

@ -0,0 +1,33 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"../../../../typings/**/*",
"common/**/*",
"public/**/*",
"types/**/*"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/logging",
"@kbn/ebt-tools",
"@kbn/kibana-react-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/observability-shared-plugin",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-context-theme",
"@kbn/shared-ux-link-redirect-app",
"@kbn/usage-collection-plugin",
"@kbn/shared-ux-router",
"@kbn/presentation-util-plugin",
"@kbn/cloud-plugin",
"@kbn/serverless",
"@kbn/entityManager-plugin",
"@kbn/entities-schema",
]
}

View file

@ -5424,6 +5424,10 @@
version "0.0.0"
uid ""
"@kbn/entityManager-app-plugin@link:x-pack/plugins/observability_solution/entity_manager_app":
version "0.0.0"
uid ""
"@kbn/entityManager-plugin@link:x-pack/plugins/entity_manager":
version "0.0.0"
uid ""