mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
# 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\ncloseseb4e7aa6
-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\ncloseseb4e7aa6
-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\ncloseseb4e7aa6
-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:
parent
a460623ef0
commit
25a7abe5b6
24 changed files with 628 additions and 64 deletions
|
@ -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(),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -12,6 +12,8 @@
|
|||
"entityManager",
|
||||
"inference",
|
||||
"dataViews",
|
||||
"unifiedSearch",
|
||||
"data",
|
||||
"share"
|
||||
],
|
||||
"requiredBundles": [
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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)',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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({
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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: {},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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()})`;
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue