Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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()
);
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"entityManager",
"inference",
"dataViews",
"unifiedSearch",
"data",
"share"
],
"requiredBundles": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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
/>
);
}
Loading
Loading