[8.x] [Inventory][ECO] Entities page search bar (#193546) (#193726)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Inventory][ECO] Entities page search bar
(#193546)](https://github.com/elastic/kibana/pull/193546)

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

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

<!--BACKPORT [{"author":{"name":"Cauê
Marcondes","email":"55978943+cauemarcondes@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-09-23T13:59:15Z","message":"[Inventory][ECO]
Entities page search bar (#193546)\n\ncloses
eb4e7aa6-14dd-48fb-a076-98ceec9cb335\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"6a0fa96141086644bc8756a37babe43204e6c076","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor","ci:project-deploy-observability","v8.16.0"],"number":193546,"url":"https://github.com/elastic/kibana/pull/193546","mergeCommit":{"message":"[Inventory][ECO]
Entities page search bar (#193546)\n\ncloses
eb4e7aa6-14dd-48fb-a076-98ceec9cb335\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"6a0fa96141086644bc8756a37babe43204e6c076"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/193546","number":193546,"mergeCommit":{"message":"[Inventory][ECO]
Entities page search bar (#193546)\n\ncloses
eb4e7aa6-14dd-48fb-a076-98ceec9cb335\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"6a0fa96141086644bc8756a37babe43204e6c076"}},{"branch":"8.x","label":"v8.16.0","labelRegex":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Cauê Marcondes 2024-09-23 16:46:32 +01:00 committed by GitHub
parent a460623ef0
commit 25a7abe5b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 628 additions and 64 deletions

View file

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

View file

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

View file

@ -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<typeof entityTypeRt>;
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<EntityType[], string, unknown>(
'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()
);

View file

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

View file

@ -12,6 +12,8 @@
"entityManager",
"inference",
"dataViews",
"unifiedSearch",
"data",
"share"
],
"requiredBundles": [

View file

@ -5,17 +5,18 @@
* 2.0.
*/
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import React from 'react';
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 { type AppMountParameters, type CoreStart } from '@kbn/core/public';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
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 (
<InventoryContextProvider context={context}>
<RedirectAppLinks coreStart={coreStart}>
<RouterProvider history={history} router={inventoryRouter}>
<RouteRenderer />
<InventoryHeaderActionMenu appMountParameters={appMountParameters} />
</RouterProvider>
<InventorySearchBarContextProvider>
<RouterProvider history={history} router={inventoryRouter}>
<RouteRenderer />
<InventoryHeaderActionMenu appMountParameters={appMountParameters} />
</RouterProvider>
</InventorySearchBarContextProvider>
</RedirectAppLinks>
</InventoryContextProvider>
);

View file

@ -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 <EuiBadge color="hollow">{entity[columnEntityTableId]}</EuiBadge>;
return (
<EuiBadge color="hollow">
{getEntityTypeLabel(entity[columnEntityTableId] as EntityType)}
</EuiBadge>
);
case ENTITY_LAST_SEEN:
return (
<FormattedMessage

View file

@ -4,9 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../hooks/use_kibana';
import { SearchBar } from '../search_bar';
import { getEntityManagerEnablement } from './no_data_config';
import { useEntityManager } from '../../hooks/use_entity_manager';
import { Welcome } from '../entity_enablement/welcome_modal';
@ -43,10 +45,17 @@ export function InventoryPageTemplate({ children }: { children: React.ReactNode
}),
}}
>
{children}
{showWelcomedModal ? (
<Welcome onClose={toggleWelcomedModal} onConfirm={toggleWelcomedModal} />
) : null}
<EuiFlexGroup direction="column">
<EuiFlexItem>
<SearchBar />
</EuiFlexItem>
<EuiFlexItem>
{children}
{showWelcomedModal ? (
<Welcome onClose={toggleWelcomedModal} onConfirm={toggleWelcomedModal} />
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</ObservabilityPageTemplate>
);
}

View file

@ -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<EntityType> => ({
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 (
<EuiComboBox<EntityType>
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
/>
);
}

View file

@ -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<NonNullable<SearchBarOwnProps['onQuerySubmit']>>(
({ query }, isUpdate) => {
searchBarContentSubject$.next({
kuery: query?.query as string,
entityTypes,
refresh: !isUpdate,
});
},
[entityTypes, searchBarContentSubject$]
);
return (
<UnifiedSearchBar
appName="Inventory"
displayStyle="inPage"
showDatePicker={false}
showFilterBar={false}
indexPatterns={dataView ? [dataView] : undefined}
renderQueryInputAppend={() => <EntityTypesControls onChange={handleEntityTypesChange} />}
onQuerySubmit={handleQuerySubmit}
placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', {
defaultMessage:
'Search for your entities by name or its metadata (e.g. entity.type : service)',
})}
/>
);
}

View file

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

View file

@ -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<InventorySearchBarContextType>({
searchBarContentSubject$: new Subject(),
});
export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) {
return (
<InventorySearchBarContext.Provider value={{ searchBarContentSubject$: new Subject() }}>
{children}
</InventorySearchBarContext.Provider>
);
}
export function useInventorySearchBarContext() {
const context = useContext(InventorySearchBarContext);
if (!context) {
throw new Error('Context was not found');
}
return context;
}

View file

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

View file

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

View file

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

View file

@ -4,13 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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 { Outlet, createRouter } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
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 = {
</InventoryPageTemplate>
),
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: {

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

@ -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"
]