From c82c41a7862cd2c8924833531f19f67e9d69885d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:59:15 +0100 Subject: [PATCH] [Inventory][ECO] Entities page search bar (#193546) closes https://github.com/elastic/kibana/issues/192329 https://github.com/user-attachments/assets/eb4e7aa6-14dd-48fb-a076-98ceec9cb335 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Carlos Crespo (cherry picked from commit 6a0fa96141086644bc8756a37babe43204e6c076) --- .../.storybook/get_mock_inventory_context.tsx | 10 +- .../.storybook/storybook_decorator.tsx | 2 +- .../inventory/common/entities.ts | 30 ++++++ .../inventory/common/entitites.test.ts | 99 +++++++++++++++++++ .../inventory/kibana.jsonc | 2 + .../public/components/app_root/index.tsx | 25 ++--- .../public/components/entities_grid/index.tsx | 8 +- .../inventory_page_template/index.tsx | 17 +++- .../search_bar/entity_types_controls.tsx | 67 +++++++++++++ .../public/components/search_bar/index.tsx | 85 ++++++++++++++++ .../inventory_context_provider/index.tsx | 2 +- .../index.tsx | 37 +++++++ .../hooks/use_adhoc_inventory_data_view.ts | 46 +++++++++ .../public/pages/inventory_page/index.tsx | 34 ++++++- .../inventory/public/plugin.ts | 12 +-- .../inventory/public/routes/config.tsx | 23 +++-- .../inventory/public/types.ts | 7 ++ .../utils/get_entity_type_label.test.ts | 31 ++++++ .../public/utils/get_entity_type_label.ts | 30 ++++++ .../routes/entities/get_entity_types.ts | 27 +++++ .../routes/entities/get_latest_entities.ts | 39 +++----- .../server/routes/entities/query_helper.ts | 27 +++++ .../inventory/server/routes/entities/route.ts | 26 ++++- .../inventory/tsconfig.json | 2 + 24 files changed, 626 insertions(+), 62 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/common/entitites.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_controls.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx rename x-pack/plugins/observability_solution/inventory/public/{components => context}/inventory_context_provider/index.tsx (100%) create mode 100644 x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/hooks/use_adhoc_inventory_data_view.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts diff --git a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx index 6739e21abe280..51aaeebc655f2 100644 --- a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx +++ b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx @@ -6,9 +6,12 @@ */ import { coreMock } from '@kbn/core/public/mocks'; -import { EntityManagerPublicPluginStart } from '@kbn/entityManager-plugin/public'; -import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { EntityManagerPublicPluginStart } from '@kbn/entityManager-plugin/public'; import type { InferencePublicStart } from '@kbn/inference-plugin/public'; +import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { InventoryKibanaContext } from '../public/hooks/use_kibana'; import type { ITelemetryClient } from '../public/services/telemetry/types'; @@ -23,6 +26,9 @@ export function getMockInventoryContext(): InventoryKibanaContext { inference: {} as unknown as InferencePublicStart, share: {} as unknown as SharePluginStart, telemetry: {} as unknown as ITelemetryClient, + unifiedSearch: {} as unknown as UnifiedSearchPublicPluginStart, + dataViews: {} as unknown as DataViewsPublicPluginStart, + data: {} as unknown as DataPublicPluginStart, inventoryAPIClient: { fetch: jest.fn(), stream: jest.fn(), diff --git a/x-pack/plugins/observability_solution/inventory/.storybook/storybook_decorator.tsx b/x-pack/plugins/observability_solution/inventory/.storybook/storybook_decorator.tsx index 87589988492d8..20e507e54b5d5 100644 --- a/x-pack/plugins/observability_solution/inventory/.storybook/storybook_decorator.tsx +++ b/x-pack/plugins/observability_solution/inventory/.storybook/storybook_decorator.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { ComponentType, useMemo } from 'react'; -import { InventoryContextProvider } from '../public/components/inventory_context_provider'; +import { InventoryContextProvider } from '../public/context/inventory_context_provider'; import { getMockInventoryContext } from './get_mock_inventory_context'; export function KibanaReactStorybookDecorator(Story: ComponentType) { diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index d8353cf3a97f0..cc69a4c4a687e 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -5,6 +5,8 @@ * 2.0. */ import * as t from 'io-ts'; +import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; +import { isRight } from 'fp-ts/lib/Either'; export const entityTypeRt = t.union([ t.literal('service'), @@ -15,3 +17,31 @@ export const entityTypeRt = t.union([ export type EntityType = t.TypeOf; export const MAX_NUMBER_OF_ENTITIES = 500; + +export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ + type: '*', + dataset: ENTITY_LATEST, +}); + +const entityArrayRt = t.array(entityTypeRt); +export const entityTypesRt = new t.Type( + 'entityTypesRt', + entityArrayRt.is, + (input, context) => { + if (typeof input === 'string') { + const arr = input.split(','); + const validation = entityArrayRt.decode(arr); + if (isRight(validation)) { + return t.success(validation.right); + } + } else if (Array.isArray(input)) { + const validation = entityArrayRt.decode(input); + if (isRight(validation)) { + return t.success(validation.right); + } + } + + return t.failure(input, context); + }, + (arr) => arr.join() +); diff --git a/x-pack/plugins/observability_solution/inventory/common/entitites.test.ts b/x-pack/plugins/observability_solution/inventory/common/entitites.test.ts new file mode 100644 index 0000000000000..38da7beab8d4f --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/entitites.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { isLeft, isRight } from 'fp-ts/lib/Either'; +import { type EntityType, entityTypesRt } from './entities'; + +const validate = (input: unknown) => entityTypesRt.decode(input); + +describe('entityTypesRt codec', () => { + it('should validate a valid string of entity types', () => { + const input = 'service,host,container'; + const result = validate(input); + expect(isRight(result)).toBe(true); + if (isRight(result)) { + expect(result.right).toEqual(['service', 'host', 'container']); + } + }); + + it('should validate a valid array of entity types', () => { + const input = ['service', 'host', 'container']; + const result = validate(input); + expect(isRight(result)).toBe(true); + if (isRight(result)) { + expect(result.right).toEqual(['service', 'host', 'container']); + } + }); + + it('should fail validation when the string contains invalid entity types', () => { + const input = 'service,invalidType,host'; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation when the array contains invalid entity types', () => { + const input = ['service', 'invalidType', 'host']; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation when input is not a string or array', () => { + const input = 123; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation when the array contains non-string elements', () => { + const input = ['service', 123, 'host']; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation an empty string', () => { + const input = ''; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should validate an empty array as valid', () => { + const input: unknown[] = []; + const result = validate(input); + expect(isRight(result)).toBe(true); + if (isRight(result)) { + expect(result.right).toEqual([]); + } + }); + + it('should fail validation when the string contains only commas', () => { + const input = ',,,'; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation for partial valid entities in a string', () => { + const input = 'service,invalidType'; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should fail validation for partial valid entities in an array', () => { + const input = ['service', 'invalidType']; + const result = validate(input); + expect(isLeft(result)).toBe(true); + }); + + it('should serialize a valid array back to a string', () => { + const input: EntityType[] = ['service', 'host']; + const serialized = entityTypesRt.encode(input); + expect(serialized).toBe('service,host'); + }); + + it('should serialize an empty array back to an empty string', () => { + const input: EntityType[] = []; + const serialized = entityTypesRt.encode(input); + expect(serialized).toBe(''); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/kibana.jsonc b/x-pack/plugins/observability_solution/inventory/kibana.jsonc index 9262e111c401f..524b59d37cc34 100644 --- a/x-pack/plugins/observability_solution/inventory/kibana.jsonc +++ b/x-pack/plugins/observability_solution/inventory/kibana.jsonc @@ -12,6 +12,8 @@ "entityManager", "inference", "dataViews", + "unifiedSearch", + "data", "share" ], "requiredBundles": [ diff --git a/x-pack/plugins/observability_solution/inventory/public/components/app_root/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/app_root/index.tsx index 80fc8cbe3d604..d46e2b76012d9 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/app_root/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/app_root/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { type AppMountParameters, type CoreStart } from '@kbn/core/public'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { InventoryContextProvider } from '../inventory_context_provider'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import React from 'react'; +import { InventoryContextProvider } from '../../context/inventory_context_provider'; +import { InventorySearchBarContextProvider } from '../../context/inventory_search_bar_context_provider'; import { inventoryRouter } from '../../routes/config'; -import { HeaderActionMenuItems } from './header_action_menu'; -import { InventoryStartDependencies } from '../../types'; import { InventoryServices } from '../../services/types'; +import { InventoryStartDependencies } from '../../types'; +import { HeaderActionMenuItems } from './header_action_menu'; export function AppRoot({ coreStart, @@ -38,10 +39,12 @@ export function AppRoot({ return ( - - - - + + + + + + ); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index c9b91f165fedd..d5ab911605109 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -26,6 +26,8 @@ import { ENTITY_TYPE, } from '../../../common/es_fields/entities'; import { APIReturnType } from '../../api'; +import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; +import { EntityType } from '../../../common/entities'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; @@ -139,7 +141,11 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; switch (columnEntityTableId) { case ENTITY_TYPE: - return {entity[columnEntityTableId]}; + return ( + + {getEntityTypeLabel(entity[columnEntityTableId] as EntityType)} + + ); case ENTITY_LAST_SEEN: return ( - {children} - {showWelcomedModal ? ( - - ) : null} + + + + + + {children} + {showWelcomedModal ? ( + + ) : null} + + ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_controls.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_controls.tsx new file mode 100644 index 0000000000000..f5998d52e381f --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_controls.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EntityType } from '../../../common/entities'; +import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useKibana } from '../../hooks/use_kibana'; +import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; + +interface Props { + onChange: (entityTypes: EntityType[]) => void; +} + +const toComboBoxOption = (entityType: EntityType): EuiComboBoxOptionOption => ({ + key: entityType, + label: getEntityTypeLabel(entityType), +}); + +export function EntityTypesControls({ onChange }: Props) { + const { + query: { entityTypes = [] }, + } = useInventoryParams('/*'); + + const { + services: { inventoryAPIClient }, + } = useKibana(); + + const { value, loading } = useInventoryAbortableAsync( + ({ signal }) => { + return inventoryAPIClient.fetch('GET /internal/inventory/entities/types', { signal }); + }, + [inventoryAPIClient] + ); + + const options = value?.entityTypes.map(toComboBoxOption); + const selectedOptions = entityTypes.map(toComboBoxOption); + + return ( + + isLoading={loading} + css={css` + max-width: 325px; + `} + aria-label={i18n.translate( + 'xpack.inventory.entityTypesControls.euiComboBox.accessibleScreenReaderLabel', + { defaultMessage: 'Entity types filter' } + )} + placeholder={i18n.translate( + 'xpack.inventory.entityTypesControls.euiComboBox.placeHolderLabel', + { defaultMessage: 'Types' } + )} + options={options} + selectedOptions={selectedOptions} + onChange={(newOptions) => { + onChange(newOptions.map((option) => option.key as EntityType)); + }} + isClearable + /> + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx new file mode 100644 index 0000000000000..0b3beb5e00f8c --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx @@ -0,0 +1,85 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar'; +import deepEqual from 'fast-deep-equal'; +import React, { useCallback, useEffect } from 'react'; +import { EntityType } from '../../../common/entities'; +import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; +import { useAdHocInventoryDataView } from '../../hooks/use_adhoc_inventory_data_view'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useKibana } from '../../hooks/use_kibana'; +import { EntityTypesControls } from './entity_types_controls'; + +export function SearchBar() { + const { searchBarContentSubject$ } = useInventorySearchBarContext(); + const { + services: { + unifiedSearch, + data: { + query: { queryString: queryStringService }, + }, + }, + } = useKibana(); + + const { + query: { kuery, entityTypes }, + } = useInventoryParams('/*'); + + const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui; + + const { dataView } = useAdHocInventoryDataView(); + + const syncSearchBarWithUrl = useCallback(() => { + const query = kuery ? { query: kuery, language: 'kuery' } : undefined; + if (query && !deepEqual(queryStringService.getQuery(), query)) { + queryStringService.setQuery(query); + } + + if (!query) { + queryStringService.clearQuery(); + } + }, [kuery, queryStringService]); + + useEffect(() => { + syncSearchBarWithUrl(); + }, [syncSearchBarWithUrl]); + + const handleEntityTypesChange = useCallback( + (nextEntityTypes: EntityType[]) => { + searchBarContentSubject$.next({ kuery, entityTypes: nextEntityTypes, refresh: false }); + }, + [kuery, searchBarContentSubject$] + ); + + const handleQuerySubmit = useCallback>( + ({ query }, isUpdate) => { + searchBarContentSubject$.next({ + kuery: query?.query as string, + entityTypes, + refresh: !isUpdate, + }); + }, + [entityTypes, searchBarContentSubject$] + ); + + return ( + } + onQuerySubmit={handleQuerySubmit} + placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { + defaultMessage: + 'Search for your entities by name or its metadata (e.g. entity.type : service)', + })} + /> + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/inventory_context_provider/index.tsx b/x-pack/plugins/observability_solution/inventory/public/context/inventory_context_provider/index.tsx similarity index 100% rename from x-pack/plugins/observability_solution/inventory/public/components/inventory_context_provider/index.tsx rename to x-pack/plugins/observability_solution/inventory/public/context/inventory_context_provider/index.tsx index 068086dd17ccb..e65c367cf4d81 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/inventory_context_provider/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/context/inventory_context_provider/index.tsx @@ -4,8 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; import type { InventoryKibanaContext } from '../../hooks/use_kibana'; export function InventoryContextProvider({ diff --git a/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx b/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx new file mode 100644 index 0000000000000..fc494651d6f3f --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx @@ -0,0 +1,37 @@ +/* + * 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, { createContext, useContext, type ReactChild } from 'react'; +import { Subject } from 'rxjs'; +import { EntityType } from '../../../common/entities'; + +interface InventorySearchBarContextType { + searchBarContentSubject$: Subject<{ + kuery?: string; + entityTypes?: EntityType[]; + refresh: boolean; + }>; +} + +const InventorySearchBarContext = createContext({ + searchBarContentSubject$: new Subject(), +}); + +export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) { + return ( + + {children} + + ); +} + +export function useInventorySearchBarContext() { + const context = useContext(InventorySearchBarContext); + if (!context) { + throw new Error('Context was not found'); + } + return context; +} diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_adhoc_inventory_data_view.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_adhoc_inventory_data_view.ts new file mode 100644 index 0000000000000..ea7b337da37cc --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_adhoc_inventory_data_view.ts @@ -0,0 +1,46 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { useEffect, useState } from 'react'; +import { useKibana } from './use_kibana'; +import { ENTITIES_LATEST_ALIAS } from '../../common/entities'; + +export function useAdHocInventoryDataView() { + const { + services: { dataViews, notifications }, + } = useKibana(); + const [dataView, setDataView] = useState(); + + useEffect(() => { + async function fetchDataView() { + try { + const displayError = false; + return await dataViews.create({ title: ENTITIES_LATEST_ALIAS }, undefined, displayError); + } catch (e) { + const noDataScreen = e.message.includes('No matching indices found'); + if (noDataScreen) { + return; + } + + notifications.toasts.addDanger({ + title: i18n.translate('xpack.inventory.data_view.creation_failed', { + defaultMessage: 'An error occurred while creating the data view', + }), + text: e.message, + }); + + throw e; + } + } + + fetchDataView().then(setDataView); + }, [dataViews, notifications.toasts]); + + return { dataView }; +} diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index be54ff531ca44..2fcb147cbe61f 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -6,35 +6,63 @@ */ import { EuiDataGridSorting } from '@elastic/eui'; import React from 'react'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; import { EntitiesGrid } from '../../components/entities_grid'; +import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useInventoryRouter } from '../../hooks/use_inventory_router'; import { useKibana } from '../../hooks/use_kibana'; export function InventoryPage() { + const { searchBarContentSubject$ } = useInventorySearchBarContext(); const { services: { inventoryAPIClient }, } = useKibana(); const { query } = useInventoryParams('/'); - const { sortDirection, sortField, pageIndex } = query; + const { sortDirection, sortField, pageIndex, kuery, entityTypes } = query; + const inventoryRoute = useInventoryRouter(); - const { value = { entities: [] }, loading } = useInventoryAbortableAsync( + const { + value = { entities: [] }, + loading, + refresh, + } = useInventoryAbortableAsync( ({ signal }) => { return inventoryAPIClient.fetch('GET /internal/inventory/entities', { params: { query: { sortDirection, sortField, + entityTypes: entityTypes?.length ? JSON.stringify(entityTypes) : undefined, + kuery, }, }, signal, }); }, - [inventoryAPIClient, sortDirection, sortField] + [entityTypes, inventoryAPIClient, kuery, sortDirection, sortField] ); + useEffectOnce(() => { + const searchBarContentSubscription = searchBarContentSubject$.subscribe( + ({ refresh: isRefresh, ...queryParams }) => { + if (isRefresh) { + refresh(); + } else { + inventoryRoute.push('/', { + path: {}, + query: { ...query, ...queryParams }, + }); + } + } + ); + return () => { + searchBarContentSubscription.unsubscribe(); + }; + }); + function handlePageChange(nextPage: number) { inventoryRoute.push('/', { path: {}, diff --git a/x-pack/plugins/observability_solution/inventory/public/plugin.ts b/x-pack/plugins/observability_solution/inventory/public/plugin.ts index c196ed41ae5f3..6f05d4594d152 100644 --- a/x-pack/plugins/observability_solution/inventory/public/plugin.ts +++ b/x-pack/plugins/observability_solution/inventory/public/plugin.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { from, map } from 'rxjs'; import { AppMountParameters, CoreSetup, @@ -15,8 +13,13 @@ import { Plugin, PluginInitializerContext, } from '@kbn/core/public'; -import type { Logger } from '@kbn/logging'; import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; +import { i18n } from '@kbn/i18n'; +import type { Logger } from '@kbn/logging'; +import { from, map } from 'rxjs'; +import { createCallInventoryAPI } from './api'; +import { TelemetryService } from './services/telemetry/telemetry_service'; +import { InventoryServices } from './services/types'; import type { ConfigSchema, InventoryPublicSetup, @@ -24,9 +27,6 @@ import type { InventorySetupDependencies, InventoryStartDependencies, } from './types'; -import { InventoryServices } from './services/types'; -import { createCallInventoryAPI } from './api'; -import { TelemetryService } from './services/telemetry/telemetry_service'; export class InventoryPlugin implements diff --git a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx index f0141a938e0bc..21fe05fb373cd 100644 --- a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx @@ -4,13 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { toNumberRt } from '@kbn/io-ts-utils'; +import { Outlet, createRouter } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; -import { createRouter, Outlet } from '@kbn/typed-react-router-config'; import React from 'react'; -import { toNumberRt } from '@kbn/io-ts-utils'; +import { ENTITY_LAST_SEEN } from '../../common/es_fields/entities'; import { InventoryPageTemplate } from '../components/inventory_page_template'; import { InventoryPage } from '../pages/inventory_page'; -import { ENTITY_LAST_SEEN } from '../../common/es_fields/entities'; +import { entityTypesRt } from '../../common/entities'; /** * The array of route definitions to be used when the application @@ -24,11 +25,17 @@ const inventoryRoutes = { ), params: t.type({ - query: t.type({ - sortField: t.string, - sortDirection: t.union([t.literal('asc'), t.literal('desc')]), - pageIndex: toNumberRt, - }), + query: t.intersection([ + t.type({ + sortField: t.string, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + pageIndex: toNumberRt, + }), + t.partial({ + entityTypes: entityTypesRt, + kuery: t.string, + }), + ]), }), defaults: { query: { diff --git a/x-pack/plugins/observability_solution/inventory/public/types.ts b/x-pack/plugins/observability_solution/inventory/public/types.ts index 47bc622048ed5..ed4a500edca68 100644 --- a/x-pack/plugins/observability_solution/inventory/public/types.ts +++ b/x-pack/plugins/observability_solution/inventory/public/types.ts @@ -13,6 +13,9 @@ import { EntityManagerPublicPluginStart, } from '@kbn/entityManager-plugin/public'; import type { InferencePublicStart, InferencePublicSetup } from '@kbn/inference-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -22,12 +25,16 @@ export interface ConfigSchema {} export interface InventorySetupDependencies { observabilityShared: ObservabilitySharedPluginSetup; inference: InferencePublicSetup; + data: DataPublicPluginSetup; entityManager: EntityManagerPublicPluginSetup; } export interface InventoryStartDependencies { observabilityShared: ObservabilitySharedPluginStart; inference: InferencePublicStart; + unifiedSearch: UnifiedSearchPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + data: DataPublicPluginStart; entityManager: EntityManagerPublicPluginStart; share: SharePluginStart; } diff --git a/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.test.ts b/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.test.ts new file mode 100644 index 0000000000000..e31a169d5d9fa --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { EntityType } from '../../common/entities'; +import { getEntityTypeLabel } from './get_entity_type_label'; + +describe('getEntityTypeLabel', () => { + it('should return "Service" for the "service" entityType', () => { + const label = getEntityTypeLabel('service'); + expect(label).toBe('Service'); + }); + + it('should return "Container" for the "container" entityType', () => { + const label = getEntityTypeLabel('container'); + expect(label).toBe('Container'); + }); + + it('should return "Host" for the "host" entityType', () => { + const label = getEntityTypeLabel('host'); + expect(label).toBe('Host'); + }); + + it('should return "N/A" for an unknown entityType', () => { + const label = getEntityTypeLabel('foo' as EntityType); + expect(label).toBe('N/A'); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.ts b/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.ts new file mode 100644 index 0000000000000..907ea70f0f0c6 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/utils/get_entity_type_label.ts @@ -0,0 +1,30 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EntityType } from '../../common/entities'; + +export function getEntityTypeLabel(entityType: EntityType) { + switch (entityType) { + case 'service': + return i18n.translate('xpack.inventory.entityType.serviceLabel', { + defaultMessage: 'Service', + }); + case 'container': + return i18n.translate('xpack.inventory.entityType.containerLabel', { + defaultMessage: 'Container', + }); + case 'host': + return i18n.translate('xpack.inventory.entityType.hostLabel', { + defaultMessage: 'Host', + }); + default: + return i18n.translate('xpack.inventory.entityType.naLabel', { + defaultMessage: 'N/A', + }); + } +} diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts new file mode 100644 index 0000000000000..bfc02c6f51a05 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts @@ -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 { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { ENTITIES_LATEST_ALIAS, EntityType } from '../../../common/entities'; +import { ENTITY_TYPE } from '../../../common/es_fields/entities'; +import { getEntityDefinitionIdWhereClause, getEntityTypesWhereClause } from './query_helper'; + +export async function getEntityTypes({ + inventoryEsClient, +}: { + inventoryEsClient: ObservabilityElasticsearchClient; +}) { + const entityTypesEsqlResponse = await inventoryEsClient.esql('get_entity_types', { + query: `FROM ${ENTITIES_LATEST_ALIAS} + | ${getEntityTypesWhereClause()} + | ${getEntityDefinitionIdWhereClause()} + | STATS count = COUNT(${ENTITY_TYPE}) BY ${ENTITY_TYPE} + `, + }); + + return entityTypesEsqlResponse.values.map(([_, val]) => val as EntityType); +} diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index e286f0e2fac75..be909308e49c3 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -5,26 +5,21 @@ * 2.0. */ -import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query'; import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; -import { MAX_NUMBER_OF_ENTITIES, type EntityType } from '../../../common/entities'; import { - ENTITY_DEFINITION_ID, + ENTITIES_LATEST_ALIAS, + MAX_NUMBER_OF_ENTITIES, + type EntityType, +} from '../../../common/entities'; +import { ENTITY_DISPLAY_NAME, ENTITY_ID, ENTITY_LAST_SEEN, ENTITY_TYPE, } from '../../../common/es_fields/entities'; - -const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ - type: '*', - dataset: ENTITY_LATEST, -}); - -const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; -const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; -const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; +import { getEntityDefinitionIdWhereClause, getEntityTypesWhereClause } from './query_helper'; export interface LatestEntity { [ENTITY_LAST_SEEN]: string; @@ -33,34 +28,32 @@ export interface LatestEntity { [ENTITY_ID]: string; } -const DEFAULT_ENTITY_TYPES = ['service', 'host', 'container']; - export async function getLatestEntities({ inventoryEsClient, sortDirection, sortField, entityTypes, + kuery, }: { inventoryEsClient: ObservabilityElasticsearchClient; sortDirection: 'asc' | 'desc'; sortField: string; entityTypes?: EntityType[]; + kuery?: string; }) { - const entityTypesFilter = entityTypes?.length ? entityTypes : DEFAULT_ENTITY_TYPES; const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { query: `FROM ${ENTITIES_LATEST_ALIAS} - | WHERE ${ENTITY_TYPE} IN (${entityTypesFilter.map((entityType) => `"${entityType}"`).join()}) - | WHERE ${ENTITY_DEFINITION_ID} IN (${[ - BUILTIN_SERVICES_FROM_ECS_DATA, - BUILTIN_HOSTS_FROM_ECS_DATA, - BUILTIN_CONTAINERS_FROM_ECS_DATA, - ] - .map((buildin) => `"${buildin}"`) - .join()}) + | ${getEntityTypesWhereClause(entityTypes)} + | ${getEntityDefinitionIdWhereClause()} | SORT ${sortField} ${sortDirection} | LIMIT ${MAX_NUMBER_OF_ENTITIES} | KEEP ${ENTITY_LAST_SEEN}, ${ENTITY_TYPE}, ${ENTITY_DISPLAY_NAME}, ${ENTITY_ID} `, + filter: { + bool: { + filter: [...kqlQuery(kuery)], + }, + }, }); return esqlResultToPlainObjects(latestEntitiesEsqlResponse); diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts new file mode 100644 index 0000000000000..c27e5ffd103aa --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts @@ -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 { EntityType } from '../../../common/entities'; +import { ENTITY_DEFINITION_ID, ENTITY_TYPE } from '../../../common/es_fields/entities'; + +const defaultEntityTypes: EntityType[] = ['service', 'host', 'container']; + +export const getEntityTypesWhereClause = (entityTypes: EntityType[] = defaultEntityTypes) => + `WHERE ${ENTITY_TYPE} IN (${entityTypes.map((entityType) => `"${entityType}"`).join()})`; + +const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; +const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; +const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; + +export const getEntityDefinitionIdWhereClause = () => + `WHERE ${ENTITY_DEFINITION_ID} IN (${[ + BUILTIN_SERVICES_FROM_ECS_DATA, + BUILTIN_HOSTS_FROM_ECS_DATA, + BUILTIN_CONTAINERS_FROM_ECS_DATA, + ] + .map((buildin) => `"${buildin}"`) + .join()})`; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts index e77dccb8b8cdb..beef1b068ed15 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts @@ -4,14 +4,33 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; import { jsonRt } from '@kbn/io-ts-utils'; import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import * as t from 'io-ts'; -import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; import { entityTypeRt } from '../../../common/entities'; import { createInventoryServerRoute } from '../create_inventory_server_route'; +import { getEntityTypes } from './get_entity_types'; import { getLatestEntities } from './get_latest_entities'; +export const getEntityTypesRoute = createInventoryServerRoute({ + endpoint: 'GET /internal/inventory/entities/types', + options: { + tags: ['access:inventory'], + }, + handler: async ({ context, logger }) => { + const coreContext = await context.core; + const inventoryEsClient = createObservabilityEsClient({ + client: coreContext.elasticsearch.client.asCurrentUser, + logger, + plugin: `@kbn/${INVENTORY_APP_ID}-plugin`, + }); + + const entityTypes = await getEntityTypes({ inventoryEsClient }); + return { entityTypes }; + }, +}); + export const listLatestEntitiesRoute = createInventoryServerRoute({ endpoint: 'GET /internal/inventory/entities', params: t.type({ @@ -22,6 +41,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ }), t.partial({ entityTypes: jsonRt.pipe(t.array(entityTypeRt)), + kuery: t.string, }), ]), }), @@ -36,13 +56,14 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ plugin: `@kbn/${INVENTORY_APP_ID}-plugin`, }); - const { sortDirection, sortField, entityTypes } = params.query; + const { sortDirection, sortField, entityTypes, kuery } = params.query; const latestEntities = await getLatestEntities({ inventoryEsClient, sortDirection, sortField, entityTypes, + kuery, }); return { entities: latestEntities }; @@ -51,4 +72,5 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ export const entitiesRoutes = { ...listLatestEntitiesRoute, + ...getEntityTypesRoute, }; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index 324dc1d08cdb9..009221ad0c0ca 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -36,6 +36,8 @@ "@kbn/entities-schema", "@kbn/i18n-react", "@kbn/io-ts-utils", + "@kbn/unified-search-plugin", + "@kbn/data-plugin", "@kbn/core-analytics-browser", "@kbn/core-http-browser" ]