[Inventory][ECO] Entity type Remove Control groups filter (#202177)

closes https://github.com/elastic/kibana/issues/201584

- Removes control group entity types filter
- Adds multi-select entity types filer
- Add kuery to url
- Remove unified entities page
- Adding telemetry when entity type is filtered
- Refactoring...



https://github.com/user-attachments/assets/98fb11ab-76e7-497b-af86-86378c6bfd7f

---------

Co-authored-by: Irene Blanco <irene.blanco@elastic.co>
This commit is contained in:
Cauê Marcondes 2024-12-02 09:14:35 +00:00 committed by GitHub
parent 10f50564ac
commit d8f3f4cb3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 717 additions and 935 deletions

View file

@ -34,7 +34,7 @@ export class EntitiesSynthtraceKibanaClient {
});
const entityDefinition: EntityDefinitionResponse = await response.json();
const hasEntityDefinitionsInstalled = entityDefinition.definitions.find(
const hasEntityDefinitionsInstalled = entityDefinition.definitions?.find(
(definition) => definition.type === 'service'
)?.state.installed;

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import { ENTITY_LATEST, entitiesAliasPattern, type EntityMetadata } from '@kbn/entities-schema';
import { decode, encode } from '@kbn/rison';
import { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
export const entityColumnIdsRt = t.union([
@ -19,49 +17,6 @@ export const entityColumnIdsRt = t.union([
export type EntityColumnIds = t.TypeOf<typeof entityColumnIdsRt>;
export const entityViewRt = t.union([t.literal('unified'), t.literal('grouped')]);
const paginationRt = t.record(t.string, t.number);
export const entityPaginationRt = new t.Type<Record<string, number> | undefined, string, unknown>(
'entityPaginationRt',
paginationRt.is,
(input, context) => {
switch (typeof input) {
case 'string': {
try {
const decoded = decode(input);
const validation = paginationRt.decode(decoded);
if (isRight(validation)) {
return t.success(validation.right);
}
return t.failure(input, context);
} catch (e) {
return t.failure(input, context);
}
}
case 'undefined':
return t.success(input);
default: {
const validation = paginationRt.decode(input);
if (isRight(validation)) {
return t.success(validation.right);
}
return t.failure(input, context);
}
}
},
(o) => encode(o)
);
export type EntityView = t.TypeOf<typeof entityViewRt>;
export type EntityPagination = t.TypeOf<typeof entityPaginationRt>;
export const defaultEntitySortField: EntityColumnIds = 'alertsCount';
export const MAX_NUMBER_OF_ENTITIES = 500;

View file

@ -0,0 +1,60 @@
/*
* 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 { decode, encode } from '@kbn/rison';
import { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
const validate = (validationRt: t.Any) => (input: unknown, context: t.Context) => {
switch (typeof input) {
case 'string': {
try {
const decoded = decode(input);
const validation = validationRt.decode(decoded);
if (isRight(validation)) {
return t.success(validation.right);
}
return t.failure(input, context);
} catch (e) {
return t.failure(input, context);
}
}
case 'undefined':
return t.success(input);
default: {
const validation = validationRt.decode(input);
if (isRight(validation)) {
return t.success(validation.right);
}
return t.failure(input, context);
}
}
};
const entityTypeCheckOptions = t.union([t.literal('on'), t.literal('off'), t.literal('mixed')]);
export type EntityTypeCheckOptions = t.TypeOf<typeof entityTypeCheckOptions>;
const entityTypeRt = t.record(t.string, entityTypeCheckOptions);
export type EntityType = t.TypeOf<typeof entityTypeRt>;
export const entityTypesRt = new t.Type<
Record<string, 'on' | 'off' | 'mixed'> | undefined,
string,
unknown
>('entityTypesRt', entityTypeRt.is, validate(entityTypeRt), (o) => encode(o));
const paginationRt = t.record(t.string, t.number);
export type EntityPagination = t.TypeOf<typeof entityPaginationRt>;
export const entityPaginationRt = new t.Type<Record<string, number> | undefined, string, unknown>(
'entityPaginationRt',
paginationRt.is,
validate(paginationRt),
(o) => encode(o)
);

View file

@ -80,25 +80,6 @@ describe('Home page', () => {
cy.contains('foo');
});
it('Shows inventory page with unified view of entities', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.contains('Group entities by: Type');
cy.getByTestSubj('groupSelectorDropdown').click();
cy.getByTestSubj('panelUnified').click();
cy.wait('@getEntities');
cy.contains('server1');
cy.contains('host');
cy.contains('synth-node-trace-logs');
cy.contains('service');
cy.contains('foo');
cy.contains('container');
});
it('Navigates to apm when clicking on a service type entity', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
@ -148,69 +129,69 @@ describe('Home page', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
'entityTypeControlGroupOptions'
);
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEntitiesTypes');
cy.wait('@getEEMStatus');
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-service').click();
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click();
cy.wait('@getGroups');
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click();
cy.wait('@getEntities');
cy.get('server1').should('not.exist');
cy.contains('synth-node-trace-logs');
cy.contains('foo').should('not.exist');
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click();
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').should('not.exist');
});
it('Filters entities by host type', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
'entityTypeControlGroupOptions'
);
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEntitiesTypes');
cy.wait('@getEEMStatus');
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-host').click();
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click();
cy.wait('@getGroups');
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click();
cy.wait('@getEntities');
cy.contains('server1');
cy.contains('synth-node-trace-logs').should('not.exist');
cy.contains('foo').should('not.exist');
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click();
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').should('not.exist');
});
it('Filters entities by container type', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
'entityTypeControlGroupOptions'
);
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEntitiesTypes');
cy.wait('@getEEMStatus');
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-container').click();
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click();
cy.wait('@getGroups');
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').click();
cy.wait('@getEntities');
cy.contains('server1').should('not.exist');
cy.contains('synth-node-trace-logs').should('not.exist');
cy.contains('foo');
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click();
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').should('not.exist');
});
it('Navigates to discover with actions button in the entities list', () => {

View file

@ -27,23 +27,31 @@ Cypress.Commands.add('loginAsSuperUser', () => {
Cypress.Commands.add(
'loginAs',
({ username, password }: { username: string; password: string }) => {
const kibanaUrl = Cypress.env('KIBANA_URL');
cy.log(`Logging in as ${username} on ${kibanaUrl}`);
cy.visit('/');
cy.request({
log: true,
method: 'POST',
url: `${kibanaUrl}/internal/security/login`,
body: {
providerType: 'basic',
providerName: 'basic',
currentURL: `${kibanaUrl}/login`,
params: { username, password },
cy.session(
username,
() => {
const kibanaUrl = Cypress.env('KIBANA_URL');
cy.log(`Logging in as ${username} on ${kibanaUrl}`);
cy.visit('/');
cy.request({
log: true,
method: 'POST',
url: `${kibanaUrl}/internal/security/login`,
body: {
providerType: 'basic',
providerName: 'basic',
currentURL: `${kibanaUrl}/login`,
params: { username, password },
},
headers: {
'kbn-xsrf': 'e2e_test',
},
});
cy.visit('/');
},
headers: {
'kbn-xsrf': 'e2e_test',
},
});
cy.visit('/');
{
cacheAcrossSpecs: true,
}
);
}
);

View file

@ -21,7 +21,7 @@
"ruleRegistry",
"share"
],
"requiredBundles": ["kibanaReact","controls"],
"requiredBundles": ["kibanaReact"],
"optionalPlugins": ["spaces", "cloud"],
"extraPublicDirs": []
}

View file

@ -27,7 +27,7 @@ describe('BadgeFilterWithPopover', () => {
});
it('opens the popover when the badge is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} />);
render(<BadgeFilterWithPopover field={field} value={value} onFilter={jest.fn()} />);
expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument();
fireEvent.click(screen.getByText(value));
expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument();
@ -35,9 +35,25 @@ describe('BadgeFilterWithPopover', () => {
});
it('copies value to clipboard when the "Copy value" button is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} />);
render(<BadgeFilterWithPopover field={field} value={value} onFilter={jest.fn()} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton'));
expect(copyToClipboard).toHaveBeenCalledWith(value);
});
it('Filter for an entity', () => {
const handleFilter = jest.fn();
render(<BadgeFilterWithPopover field={field} value={value} onFilter={handleFilter} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton'));
expect(handleFilter).toHaveBeenCalledWith(value, 'on');
});
it('Filter out an entity', () => {
const handleFilter = jest.fn();
render(<BadgeFilterWithPopover field={field} value={value} onFilter={handleFilter} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterOutButton'));
expect(handleFilter).toHaveBeenCalledWith(value, 'off');
});
});

View file

@ -18,19 +18,18 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import React, { useState } from 'react';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import type { EntityTypeCheckOptions } from '../../../common/rt_types';
interface Props {
field: string;
value: string;
onFilter: (value: string, checked: EntityTypeCheckOptions) => void;
}
export function BadgeFilterWithPopover({ field, value }: Props) {
export function BadgeFilterWithPopover({ field, value, onFilter }: Props) {
const [isOpen, setIsOpen] = useState(false);
const theme = useEuiTheme();
const { addFilter } = useUnifiedSearchContext();
return (
<EuiPopover
@ -82,7 +81,7 @@ export function BadgeFilterWithPopover({ field, value }: Props) {
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
iconType="plusInCircle"
onClick={() => {
addFilter({ fieldName: ENTITY_TYPE, operation: '+', value });
onFilter(value, 'on');
}}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
@ -92,10 +91,10 @@ export function BadgeFilterWithPopover({ field, value }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
data-test-subj="inventoryBadgeFilterWithPopoverFilterOutButton"
iconType="minusInCircle"
onClick={() => {
addFilter({ fieldName: ENTITY_TYPE, operation: '-', value });
onFilter(value, 'off');
}}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {

View file

@ -77,6 +77,7 @@ export const Grid: Story<EntityGridStoriesArgs> = (args) => {
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={() => {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -99,6 +100,7 @@ export const EmptyGrid: Story<EntityGridStoriesArgs> = (args) => {
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={() => {}}
/>
);
};

View file

@ -22,6 +22,7 @@ import { getColumns } from './grid_columns';
import { AlertsBadge } from '../alerts_badge/alerts_badge';
import { EntityName } from './entity_name';
import { EntityActions } from '../entity_actions';
import { type EntityTypeCheckOptions } from '../../../common/rt_types';
interface Props {
loading: boolean;
@ -31,6 +32,7 @@ interface Props {
pageIndex: number;
onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void;
onChangePage: (nextPage: number) => void;
onFilterByType: (value: string, checked: EntityTypeCheckOptions) => void;
}
const PAGE_SIZE = 20;
@ -43,6 +45,7 @@ export function EntitiesGrid({
pageIndex,
onChangePage,
onChangeSort,
onFilterByType,
}: Props) {
const [showActions, setShowActions] = useState<boolean>(true);
@ -84,7 +87,13 @@ export function EntitiesGrid({
return entity?.alertsCount ? <AlertsBadge entity={entity} /> : null;
case 'entityType':
return <BadgeFilterWithPopover field={ENTITY_TYPE} value={entityType} />;
return (
<BadgeFilterWithPopover
field={ENTITY_TYPE}
value={entityType}
onFilter={onFilterByType}
/>
);
case 'entityLastSeenTimestamp':
return (
@ -120,7 +129,7 @@ export function EntitiesGrid({
return null;
}
},
[entities]
[entities, onFilterByType]
);
if (loading) {

View file

@ -9,12 +9,7 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { EuiThemeProvider } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import { InventorySummary } from './inventory_summary';
// Do not test the GroupSelector, as it needs a lot more complicated setup
jest.mock('./group_selector', () => ({
GroupSelector: () => <>Selector</>,
}));
import { EntitiesSummary } from '.';
function MockEnvWrapper({ children }: { children?: React.ReactNode }) {
return (
@ -24,19 +19,19 @@ function MockEnvWrapper({ children }: { children?: React.ReactNode }) {
);
}
describe('InventorySummary', () => {
describe('EntitiesSummary', () => {
it('renders the total entities without any group totals', () => {
render(<InventorySummary totalEntities={10} />, { wrapper: MockEnvWrapper });
render(<EntitiesSummary totalEntities={10} />, { wrapper: MockEnvWrapper });
expect(screen.getByText('10 Entities')).toBeInTheDocument();
expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument();
});
it('renders the total entities with group totals', () => {
render(<InventorySummary totalEntities={15} totalGroups={3} />, { wrapper: MockEnvWrapper });
render(<EntitiesSummary totalEntities={15} totalGroups={3} />, { wrapper: MockEnvWrapper });
expect(screen.getByText('15 Entities')).toBeInTheDocument();
expect(screen.queryByText('3 Groups')).toBeInTheDocument();
});
it("won't render either totals when not provided anything", () => {
render(<InventorySummary />, { wrapper: MockEnvWrapper });
render(<EntitiesSummary />, { wrapper: MockEnvWrapper });
expect(screen.queryByTestId('inventorySummaryEntitiesTotal')).not.toBeInTheDocument();
expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument();
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
export function EntitiesSummary({
totalEntities,
totalGroups,
}: {
totalEntities?: number;
totalGroups?: number;
}) {
const { euiTheme } = useEuiTheme();
const isGrouped = totalGroups !== undefined;
return (
<EuiFlexGroup
gutterSize="none"
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
>
{totalEntities !== undefined && (
<EuiFlexItem grow={false}>
<span
data-test-subj="inventorySummaryEntitiesTotal"
css={css`
border-right: ${isGrouped ? euiTheme.border.thin : 'none'};
margin-right: ${euiTheme.size.base};
padding-right: ${euiTheme.size.base};
`}
>
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.entitiesTotalLabel"
defaultMessage="{total} Entities"
values={{ total: totalEntities }}
/>
</span>
</EuiFlexItem>
)}
{isGrouped ? (
<EuiFlexItem grow={false}>
<span data-test-subj="inventorySummaryGroupsTotal">
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.groupsTotalLabel"
defaultMessage="{total} Groups"
values={{ total: totalGroups }}
/>
</span>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
}

View file

@ -7,7 +7,7 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import React from 'react';
export function InventoryPanelBadge({
export function EntityCountBadge({
name,
value,
'data-test-subj': dataTestSubj,

View file

@ -7,10 +7,9 @@
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import { EntityGroupAccordion } from '.';
import { InventoryGroupAccordion } from './inventory_group_accordion';
describe('Grouped Inventory Accordion', () => {
describe('EntityGroupAccordion', () => {
it('renders with correct values', () => {
const props = {
groupBy: 'entity.type',
@ -26,14 +25,14 @@ describe('Grouped Inventory Accordion', () => {
],
};
render(
<InventoryGroupAccordion
<EntityGroupAccordion
groupValue={props.groups[0]['entity.type']}
groupCount={props.groups[0].count}
groupBy={props.groupBy}
/>
);
expect(screen.getByText(props.groups[0]['entity.type'])).toBeInTheDocument();
const container = screen.getByTestId('inventoryPanelBadgeEntitiesCount_entity.type_host');
const container = screen.getByTestId('entityCountBadge_entity.type_host');
expect(within(container).getByText('Entities:')).toBeInTheDocument();
expect(within(container).getByText(props.groups[0].count)).toBeInTheDocument();
});

View file

@ -5,15 +5,16 @@
* 2.0.
*/
import { EuiDataGridSorting } from '@elastic/eui';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { type EntityColumnIds } from '../../../common/entities';
import {
type EntityTypeCheckOptions,
entityPaginationRt,
type EntityColumnIds,
type EntityPagination,
} from '../../../common/entities';
entityTypesRt,
} from '../../../common/rt_types';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import { useKibana } from '../../hooks/use_kibana';
@ -24,29 +25,14 @@ interface Props {
groupValue: string;
}
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function GroupedEntitiesGrid({ groupValue }: Props) {
const { query } = useInventoryParams('/');
const { sortField, sortDirection, pagination: paginationQuery } = query;
const { sortField, sortDirection, kuery } = query;
const { pagination, entityTypes } = useInventoryDecodedQueryParams();
const inventoryRoute = useInventoryRouter();
let pagination: EntityPagination | undefined = {};
const { stringifiedEsQuery } = useUnifiedSearchContext();
try {
pagination = paginationDecoder(paginationQuery);
} catch (error) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
pagination: undefined,
},
});
window.location.reload();
}
const pageIndex = pagination?.[groupValue] ?? 0;
const { refreshSubject$, isControlPanelsInitiated } = useUnifiedSearchContext();
const { refreshSubject$ } = useUnifiedSearchContext();
const {
services: { inventoryAPIClient },
} = useKibana();
@ -57,28 +43,19 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
refresh,
} = useInventoryAbortableAsync(
({ signal }) => {
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
esQuery: stringifiedEsQuery,
entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined,
},
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
kuery,
entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined,
},
signal,
});
}
},
signal,
});
},
[
groupValue,
inventoryAPIClient,
sortDirection,
sortField,
isControlPanelsInitiated,
stringifiedEsQuery,
]
[groupValue, inventoryAPIClient, sortDirection, sortField, kuery]
);
useEffectOnce(() => {
@ -111,6 +88,16 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
});
}
function handleEntityTypeFilter(entityType: string, checkOption: EntityTypeCheckOptions) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
entityTypes: entityTypesRt.encode({ ...entityTypes, [entityType]: checkOption }),
},
});
}
return (
<EntitiesGrid
entities={value.entities}
@ -120,6 +107,7 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleEntityTypeFilter}
/>
);
}

View file

@ -8,27 +8,22 @@ import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, useEuiTheme } from '@elast
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
import { EntityCountBadge } from './entity_count_badge';
import { GroupedEntitiesGrid } from './grouped_entities_grid';
import { InventoryPanelBadge } from './inventory_panel_badge';
const ENTITIES_COUNT_BADGE = i18n.translate(
'xpack.inventory.inventoryGroupPanel.entitiesBadgeLabel',
{ defaultMessage: 'Entities' }
);
export interface InventoryGroupAccordionProps {
export interface Props {
groupBy: string;
groupValue: string;
groupCount: number;
isLoading?: boolean;
}
export function InventoryGroupAccordion({
groupBy,
groupValue,
groupCount,
isLoading,
}: InventoryGroupAccordionProps) {
export function EntityGroupAccordion({ groupBy, groupValue, groupCount, isLoading }: Props) {
const { euiTheme } = useEuiTheme();
const [open, setOpen] = useState(false);
@ -55,8 +50,8 @@ export function InventoryGroupAccordion({
}
buttonElement="div"
extraAction={
<InventoryPanelBadge
data-test-subj={`inventoryPanelBadgeEntitiesCount_${groupBy}_${groupValue}`}
<EntityCountBadge
data-test-subj={`entityCountBadge_${groupBy}_${groupValue}`}
name={ENTITIES_COUNT_BADGE}
value={groupCount}
/>

View file

@ -0,0 +1,24 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { GroupBySelector } from '.';
import { InventoryComponentWrapperMock } from '../entity_group_accordion/mock/inventory_component_wrapper_mock';
describe('GroupBySelector', () => {
beforeEach(() => {
render(
<InventoryComponentWrapperMock>
<GroupBySelector />
</InventoryComponentWrapperMock>
);
});
it('Should default to Type', async () => {
expect(await screen.findByText('Group entities by: Type')).toBeInTheDocument();
});
});

View file

@ -5,49 +5,17 @@
* 2.0.
*/
import { EuiPopover, EuiContextMenu, EuiButtonEmpty } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EntityView } from '../../../common/entities';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import React, { useCallback, useState } from 'react';
const GROUP_LABELS: Record<EntityView, string> = {
unified: i18n.translate('xpack.inventory.groupedInventoryPage.noneLabel', {
defaultMessage: 'None',
}),
grouped: i18n.translate('xpack.inventory.groupedInventoryPage.typeLabel', {
defaultMessage: 'Type',
}),
};
const ENTITY_TYPE_LABEL = i18n.translate('xpack.inventory.groupedInventoryPage.typeLabel', {
defaultMessage: 'Type',
});
export interface GroupedSelectorProps {
groupSelected: string;
onGroupChange: (groupSelection: string) => void;
}
export function GroupSelector() {
const { query } = useInventoryParams('/');
const inventoryRoute = useInventoryRouter();
export function GroupBySelector() {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const groupBy = query.view ?? 'grouped';
const onGroupChange = (selected: EntityView) => {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: {
...rest,
view: groupBy === selected ? 'unified' : selected,
},
});
};
const isGroupSelected = (groupKey: EntityView) => {
return groupBy === groupKey;
};
const panels = [
{
@ -56,17 +24,10 @@ export function GroupSelector() {
defaultMessage: 'Select grouping',
}),
items: [
{
'data-test-subj': 'panelUnified',
name: GROUP_LABELS.unified,
icon: isGroupSelected('unified') ? 'check' : 'empty',
onClick: () => onGroupChange('unified'),
},
{
'data-test-subj': 'panelType',
name: GROUP_LABELS.grouped,
icon: isGroupSelected('grouped') ? 'check' : 'empty',
onClick: () => onGroupChange('grouped'),
name: ENTITY_TYPE_LABEL,
icon: 'check',
},
],
},
@ -83,13 +44,13 @@ export function GroupSelector() {
iconSize="s"
iconType="arrowDown"
onClick={onButtonClick}
title={GROUP_LABELS[groupBy]}
title={ENTITY_TYPE_LABEL}
size="s"
>
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.groupedByLabel"
defaultMessage={`Group entities by: {grouping}`}
values={{ grouping: GROUP_LABELS[groupBy] }}
values={{ grouping: ENTITY_TYPE_LABEL }}
/>
</EuiButtonEmpty>
);

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GroupSelector } from './group_selector';
import { InventoryComponentWrapperMock } from './mock/inventory_component_wrapper_mock';
describe('GroupSelector', () => {
beforeEach(() => {
render(
<InventoryComponentWrapperMock>
<GroupSelector />
</InventoryComponentWrapperMock>
);
});
it('Should default to Type', async () => {
expect(await screen.findByText('Group entities by: Type')).toBeInTheDocument();
});
it.skip('Should change to None', async () => {
const user = userEvent.setup();
const selector = screen.getByText('Group entities by: Type');
expect(selector).toBeInTheDocument();
await user.click(selector);
const noneOption = screen.getByTestId('panelUnified');
expect(noneOption).toBeInTheDocument();
await user.click(noneOption);
expect(await screen.findByText('Group entities by: None')).toBeInTheDocument();
});
});

View file

@ -1,70 +0,0 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { flattenObject } from '@kbn/observability-utils-common/object/flatten_object';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useKibana } from '../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { InventoryGroupAccordion } from './inventory_group_accordion';
import { InventorySummary } from './inventory_summary';
export function GroupedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
useUnifiedSearchContext();
const {
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
refresh,
loading,
} = useInventoryAbortableAsync(
({ signal }) => {
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
params: {
path: {
field: ENTITY_TYPE,
},
query: { esQuery: stringifiedEsQuery },
},
signal,
});
}
},
[inventoryAPIClient, stringifiedEsQuery, isControlPanelsInitiated]
);
useEffectOnce(() => {
const refreshSubscription = refreshSubject$.subscribe(refresh);
return () => refreshSubscription.unsubscribe();
});
return (
<>
<InventorySummary totalEntities={value.entitiesCount} totalGroups={value.groups.length} />
<EuiSpacer size="m" />
{value.groups.map((group) => {
const groupValue = flattenObject(group)[value.groupBy];
return (
<InventoryGroupAccordion
key={`${value.groupBy}-${groupValue}`}
groupBy={value.groupBy}
groupValue={groupValue}
groupCount={group.count}
isLoading={loading}
/>
);
})}
</>
);
}

View file

@ -1,69 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { GroupSelector } from './group_selector';
export function InventorySummary({
totalEntities,
totalGroups,
}: {
totalEntities?: number;
totalGroups?: number;
}) {
const { euiTheme } = useEuiTheme();
const isGrouped = totalGroups !== undefined;
return (
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup
gutterSize="none"
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
>
{totalEntities !== undefined && (
<EuiFlexItem grow={false}>
<span
data-test-subj="inventorySummaryEntitiesTotal"
css={css`
border-right: ${isGrouped ? euiTheme.border.thin : 'none'};
margin-right: ${euiTheme.size.base};
padding-right: ${euiTheme.size.base};
`}
>
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.entitiesTotalLabel"
defaultMessage="{total} Entities"
values={{ total: totalEntities }}
/>
</span>
</EuiFlexItem>
)}
{isGrouped ? (
<EuiFlexItem grow={false}>
<span data-test-subj="inventorySummaryGroupsTotal">
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.groupsTotalLabel"
defaultMessage="{total} Groups"
values={{ total: totalGroups }}
/>
</span>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<GroupSelector />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,98 +0,0 @@
/*
* 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 {
ControlGroupRenderer,
ControlGroupRendererApi,
ControlGroupRuntimeState,
} from '@kbn/controls-plugin/public';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { ControlPanels, useControlPanels } from '@kbn/observability-shared-plugin/public';
import React, { useCallback, useEffect, useRef } from 'react';
import { skip, Subscription } from 'rxjs';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
const controlPanelDefinitions: ControlPanels = {
[ENTITY_TYPE]: {
order: 0,
type: 'optionsListControl',
fieldName: ENTITY_TYPE,
width: 'small',
grow: false,
title: 'Type',
},
};
export function ControlGroups() {
const {
isControlPanelsInitiated,
setIsControlPanelsInitiated,
dataView,
searchState,
onPanelFiltersChange,
} = useUnifiedSearchContext();
const [controlPanels, setControlPanels] = useControlPanels(controlPanelDefinitions, dataView);
const subscriptions = useRef<Subscription>(new Subscription());
const getInitialInput = useCallback(
() => async () => {
const initialInput: Partial<ControlGroupRuntimeState> = {
chainingSystem: 'HIERARCHICAL',
labelPosition: 'oneLine',
initialChildControlState: controlPanels,
};
return { initialState: initialInput };
},
[controlPanels]
);
const loadCompleteHandler = useCallback(
(controlGroup: ControlGroupRendererApi) => {
if (!controlGroup) return;
subscriptions.current.add(
controlGroup.filters$.pipe(skip(1)).subscribe((newFilters = []) => {
onPanelFiltersChange(newFilters);
})
);
subscriptions.current.add(
controlGroup.getInput$().subscribe(({ initialChildControlState }) => {
if (!isControlPanelsInitiated) {
setIsControlPanelsInitiated(true);
}
setControlPanels(initialChildControlState);
})
);
},
[isControlPanelsInitiated, onPanelFiltersChange, setControlPanels, setIsControlPanelsInitiated]
);
useEffect(() => {
const currentSubscriptions = subscriptions.current;
return () => {
currentSubscriptions.unsubscribe();
};
}, []);
if (!dataView) {
return null;
}
return (
<div>
<ControlGroupRenderer
getCreationOptions={getInitialInput()}
onApiAvailable={loadCompleteHandler}
query={searchState.query}
compressed={false}
filters={searchState.filters}
/>
</div>
);
}

View file

@ -0,0 +1,146 @@
/*
* 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 {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import { entityTypesRt, type EntityType } from '../../../common/rt_types';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import { useKibana } from '../../hooks/use_kibana';
import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status';
export function EntityTypesMultiSelect() {
const inventoryRoute = useInventoryRouter();
const { query } = useInventoryParams('/*');
const { entityTypes: selectedEntityTypes } = useInventoryDecodedQueryParams();
const {
services: { inventoryAPIClient, telemetry },
} = useKibana();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { value, loading } = useInventoryAbortableAsync(
({ signal }) => inventoryAPIClient.fetch('GET /internal/inventory/entities/types', { signal }),
[inventoryAPIClient]
);
const items = useMemo(
() =>
value?.entityTypes.map((type): EuiSelectableOption => {
const checked = selectedEntityTypes?.[type];
return {
label: type,
checked,
'data-test-subj': `entityTypes_multiSelect_filter_selection_${type}`,
};
}) || [],
[selectedEntityTypes, value?.entityTypes]
);
const registerEntityTypeFilteredEvent = useCallback(
({ filterEntityTypes }: { filterEntityTypes: EntityType }) => {
const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(filterEntityTypes);
telemetry.reportEntityInventoryEntityTypeFiltered({
include_entity_types: entityTypesOn,
exclude_entity_types: entityTypesOff,
});
},
[telemetry]
);
function handleEntityTypeChecked(nextItems: EntityType) {
registerEntityTypeFilteredEvent({ filterEntityTypes: nextItems });
inventoryRoute.push('/', {
path: {},
query: {
...query,
entityTypes: entityTypesRt.encode(nextItems),
},
});
}
return (
<EuiFilterGroup>
<EuiPopover
id="entityTypeMultiSelector"
button={
<EuiFilterButton
data-test-subj="entityTypes_multiSelect_filter"
iconType="arrowDown"
badgeColor="success"
onClick={() => setIsPopoverOpen((state) => !state)}
isSelected={isPopoverOpen}
numFilters={items.filter((item) => item.checked !== 'off').length}
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
numActiveFilters={items.filter((item) => item.checked === 'on').length}
>
{i18n.translate('xpack.inventory.entityTypesMultSelect.typeFilterButtonLabel', {
defaultMessage: 'Type',
})}
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="none"
>
<EuiSelectable
allowExclusions
searchable
searchProps={{
placeholder: i18n.translate(
'xpack.inventory.entityTypesMultSelect.euiSelectable.placeholder',
{ defaultMessage: 'Filter types' }
),
compressed: true,
}}
aria-label={i18n.translate(
'xpack.inventory.entityTypesMultSelect.euiSelectable.typeLabel',
{ defaultMessage: 'Entity type' }
)}
options={items}
onChange={(newOptions) => {
handleEntityTypeChecked(
newOptions
.filter((item) => item.checked)
.reduce<EntityType>((acc, curr) => ({ ...acc, [curr.label]: curr.checked! }), {})
);
}}
isLoading={loading}
loadingMessage={i18n.translate(
'xpack.inventory.entityTypesMultSelect.euiSelectable.loading',
{ defaultMessage: 'Loading types' }
)}
emptyMessage={i18n.translate(
'xpack.inventory.entityTypesMultSelect.euiSelectable.empty',
{ defaultMessage: 'No types available' }
)}
noMatchesMessage={i18n.translate(
'xpack.inventory.entityTypesMultSelect.euiSelectable.notFound',
{ defaultMessage: 'No types found' }
)}
>
{(list, search) => (
<div style={{ width: 300 }}>
<EuiPopoverTitle paddingSize="s">{search}</EuiPopoverTitle>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
</EuiFilterGroup>
);
}

View file

@ -4,19 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Query } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import type { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar';
import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect } from 'react';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import { useKibana } from '../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback';
import { ControlGroups } from './control_groups';
import { EntityTypesMultiSelect } from './entity_types_multi_select';
export function SearchBar() {
const { refreshSubject$, dataView, searchState, onQueryChange } = useUnifiedSearchContext();
const { refreshSubject$, dataView } = useUnifiedSearchContext();
const inventoryRoute = useInventoryRouter();
const {
query,
query: { kuery },
} = useInventoryParams('/*');
const {
services: {
unifiedSearch,
@ -30,44 +35,40 @@ export function SearchBar() {
const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui;
const syncSearchBarWithUrl = useCallback(() => {
const query = searchState.query;
if (query && !deepEqual(queryStringService.getQuery(), query)) {
queryStringService.setQuery(query);
const _query = kuery ? { query: kuery, language: 'kuery' } : undefined;
if (_query && !deepEqual(queryStringService.getQuery(), _query)) {
queryStringService.setQuery(_query);
}
if (!query) {
if (!_query) {
queryStringService.clearQuery();
}
}, [searchState.query, queryStringService]);
}, [kuery, queryStringService]);
useEffect(() => {
syncSearchBarWithUrl();
}, [syncSearchBarWithUrl]);
const registerSearchSubmittedEvent = useCallback(
({ searchQuery, searchIsUpdate }: { searchQuery?: Query; searchIsUpdate?: boolean }) => {
telemetry.reportEntityInventorySearchQuerySubmitted({
kuery_fields: getKqlFieldsWithFallback(searchQuery?.query as string),
action: searchIsUpdate ? 'submit' : 'refresh',
});
},
[telemetry]
);
const handleQuerySubmit = useCallback<NonNullable<SearchBarOwnProps['onQuerySubmit']>>(
({ query = { language: 'kuery', query: '' } }, isUpdate) => {
if (isUpdate) {
onQueryChange(query);
} else {
({ query: _query = { language: 'kuery', query: '' } }, isUpdate) => {
inventoryRoute.push('/', {
path: {},
query: {
...query,
kuery: _query?.query as string,
},
});
if (!isUpdate) {
refreshSubject$.next();
}
registerSearchSubmittedEvent({
searchQuery: query,
searchIsUpdate: isUpdate,
telemetry.reportEntityInventorySearchQuerySubmitted({
kuery_fields: getKqlFieldsWithFallback(_query?.query as string),
action: isUpdate ? 'submit' : 'refresh',
});
},
[registerSearchSubmittedEvent, onQueryChange, refreshSubject$]
[inventoryRoute, query, telemetry, refreshSubject$]
);
return (
@ -75,16 +76,14 @@ export function SearchBar() {
appName="Inventory"
displayStyle="inPage"
indexPatterns={dataView ? [dataView] : undefined}
renderQueryInputAppend={() => <ControlGroups />}
renderQueryInputAppend={() => <EntityTypesMultiSelect />}
onQuerySubmit={handleQuerySubmit}
placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', {
defaultMessage:
'Search for your entities by name or its metadata (e.g. entity.type : service)',
})}
showDatePicker={false}
showFilterBar
showQueryInput
showQueryMenu
showFilterBar={false}
/>
);
}

View file

@ -1,117 +0,0 @@
/*
* 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 { EuiDataGridSorting } from '@elastic/eui';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import {
entityPaginationRt,
type EntityColumnIds,
type EntityPagination,
} from '../../../common/entities';
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';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { EntitiesGrid } from '../entities_grid';
import { InventorySummary } from '../grouped_inventory/inventory_summary';
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function UnifiedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
useUnifiedSearchContext();
const { query } = useInventoryParams('/');
const { sortDirection, sortField, pagination: paginationQuery } = query;
let pagination: EntityPagination | undefined = {};
const inventoryRoute = useInventoryRouter();
try {
pagination = paginationDecoder(paginationQuery);
} catch (error) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
pagination: undefined,
},
});
window.location.reload();
}
const pageIndex = pagination?.unified ?? 0;
const {
value = { entities: [] },
loading,
refresh,
} = useInventoryAbortableAsync(
({ signal }) => {
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
esQuery: stringifiedEsQuery,
},
},
signal,
});
}
},
[inventoryAPIClient, sortDirection, sortField, isControlPanelsInitiated, stringifiedEsQuery]
);
useEffectOnce(() => {
const refreshSubscription = refreshSubject$.subscribe(refresh);
return () => refreshSubscription.unsubscribe();
});
function handlePageChange(nextPage: number) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
pagination: entityPaginationRt.encode({
...pagination,
unified: nextPage,
}),
},
});
}
function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
sortField: sorting.id as EntityColumnIds,
sortDirection: sorting.direction,
},
});
}
return (
<>
<InventorySummary />
<EntitiesGrid
entities={value.entities}
loading={loading}
sortDirection={sortDirection}
sortField={sortField}
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
/>
</>
);
}

View file

@ -6,9 +6,9 @@
*/
import { useCallback, useMemo } from 'react';
import type { InventoryEntity } from '../../common/entities';
import { useKibana } from './use_kibana';
import { useFetchEntityDefinition } from './use_fetch_entity_definition';
import { useAdHocDataView } from './use_adhoc_data_view';
import { useFetchEntityDefinition } from './use_fetch_entity_definition';
import { useKibana } from './use_kibana';
export const useDiscoverRedirect = (entity: InventoryEntity) => {
const {

View file

@ -0,0 +1,60 @@
/*
* 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 { decodeOrThrow } from '@kbn/io-ts-utils';
import { useCallback, useMemo } from 'react';
import {
entityPaginationRt,
entityTypesRt,
type EntityPagination,
type EntityType,
} from '../../common/rt_types';
import { useInventoryParams } from './use_inventory_params';
import { useInventoryRouter } from './use_inventory_router';
const entityTypeDecoder = decodeOrThrow(entityTypesRt);
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function useInventoryDecodedQueryParams() {
const inventoryRoute = useInventoryRouter();
const {
query,
query: { entityTypes, pagination },
} = useInventoryParams('/*');
const resetUrlParam = useCallback(
(queryParamName: string) => {
inventoryRoute.push('/', {
path: {},
query: {
...query,
[queryParamName]: undefined,
},
});
},
[inventoryRoute, query]
);
const selectedEntityTypes: EntityType = useMemo(() => {
try {
return entityTypeDecoder(entityTypes) || {};
} catch (e) {
resetUrlParam('entityTypes');
return {};
}
}, [entityTypes, resetUrlParam]);
const selectedPagination: EntityPagination = useMemo(() => {
try {
return paginationDecoder(pagination) || {};
} catch (error) {
resetUrlParam('pagination');
return {};
}
}, [pagination, resetUrlParam]);
return { entityTypes: selectedEntityTypes, pagination: selectedPagination };
}

View file

@ -4,162 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { buildEsQuery, type Filter, fromKueryExpression, type Query } from '@kbn/es-query';
import createContainer from 'constate';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { map, Subject, Subscription, tap } from 'rxjs';
import { generateFilters } from '@kbn/data-plugin/public';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import deepEqual from 'fast-deep-equal';
import { i18n } from '@kbn/i18n';
import { useKibanaQuerySettings } from '@kbn/observability-shared-plugin/public';
import { useState } from 'react';
import { Subject } from 'rxjs';
import { ENTITIES_LATEST_ALIAS } from '../../common/entities';
import { useAdHocDataView } from './use_adhoc_data_view';
import { useUnifiedSearchUrl } from './use_unified_search_url';
import { useKibana } from './use_kibana';
function useUnifiedSearch() {
const [isControlPanelsInitiated, setIsControlPanelsInitiated] = useState(false);
const { dataView } = useAdHocDataView(ENTITIES_LATEST_ALIAS);
const [refreshSubject$] = useState<Subject<void>>(new Subject());
const { searchState, setSearchState } = useUnifiedSearchUrl();
const kibanaQuerySettings = useKibanaQuerySettings();
const {
services: {
data: {
query: { filterManager: filterManagerService, queryString: queryStringService },
},
notifications,
},
} = useKibana();
useEffectOnce(() => {
if (!deepEqual(filterManagerService.getFilters(), searchState.filters)) {
filterManagerService.setFilters(
searchState.filters.map((item) => ({
...item,
meta: { ...item.meta, index: dataView?.id },
}))
);
}
if (!deepEqual(queryStringService.getQuery(), searchState.query)) {
queryStringService.setQuery(searchState.query);
}
});
useEffect(() => {
const subscription = new Subscription();
subscription.add(
filterManagerService
.getUpdates$()
.pipe(
map(() => filterManagerService.getFilters()),
tap((filters) => setSearchState({ type: 'SET_FILTERS', filters }))
)
.subscribe()
);
subscription.add(
queryStringService
.getUpdates$()
.pipe(
map(() => queryStringService.getQuery() as Query),
tap((query) => setSearchState({ type: 'SET_QUERY', query }))
)
.subscribe()
);
return () => {
subscription.unsubscribe();
};
}, [filterManagerService, queryStringService, setSearchState]);
const validateQuery = useCallback(
(query: Query) => {
fromKueryExpression(query.query, kibanaQuerySettings);
},
[kibanaQuerySettings]
);
const onQueryChange = useCallback(
(query: Query) => {
try {
validateQuery(query);
setSearchState({ type: 'SET_QUERY', query });
} catch (e) {
const err = e as Error;
notifications.toasts.addDanger({
title: i18n.translate('xpack.inventory.unifiedSearchContext.queryError', {
defaultMessage: 'Error while updating the new query',
}),
text: err.message,
});
}
},
[validateQuery, setSearchState, notifications.toasts]
);
const onPanelFiltersChange = useCallback(
(panelFilters: Filter[]) => {
setSearchState({ type: 'SET_PANEL_FILTERS', panelFilters });
},
[setSearchState]
);
const onFiltersChange = useCallback(
(filters: Filter[]) => {
setSearchState({ type: 'SET_FILTERS', filters });
},
[setSearchState]
);
const addFilter = useCallback(
({
fieldName,
operation,
value,
}: {
fieldName: string;
value: string;
operation: '+' | '-';
}) => {
if (dataView) {
const newFilters = generateFilters(
filterManagerService,
fieldName,
value,
operation,
dataView
);
setSearchState({ type: 'SET_FILTERS', filters: [...newFilters, ...searchState.filters] });
}
},
[dataView, filterManagerService, searchState.filters, setSearchState]
);
const stringifiedEsQuery = useMemo(() => {
if (dataView) {
return JSON.stringify(
buildEsQuery(dataView, searchState.query, [
...searchState.panelFilters,
...searchState.filters,
])
);
}
}, [dataView, searchState.panelFilters, searchState.filters, searchState.query]);
return {
isControlPanelsInitiated,
setIsControlPanelsInitiated,
dataView,
refreshSubject$,
searchState,
addFilter,
stringifiedEsQuery,
onQueryChange,
onPanelFiltersChange,
onFiltersChange,
};
}

View file

@ -1,100 +0,0 @@
/*
* 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 { FilterStateStore } from '@kbn/es-query';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import * as t from 'io-ts';
import { useReducer } from 'react';
import deepEqual from 'fast-deep-equal';
const FilterRT = t.intersection([
t.type({
meta: t.partial({
alias: t.union([t.null, t.string]),
disabled: t.boolean,
negate: t.boolean,
controlledBy: t.string,
group: t.string,
index: t.string,
isMultiIndex: t.boolean,
type: t.string,
key: t.string,
params: t.any,
value: t.any,
}),
}),
t.partial({
query: t.record(t.string, t.any),
$state: t.type({
store: enumeration('FilterStateStore', FilterStateStore),
}),
}),
]);
const FiltersRT = t.array(FilterRT);
const QueryStateRT = t.type({
language: t.string,
query: t.union([t.string, t.record(t.string, t.any)]),
});
const SearchStateRT = t.type({
panelFilters: FiltersRT,
filters: FiltersRT,
query: QueryStateRT,
});
const encodeUrlState = SearchStateRT.encode;
const decodeUrlState = (value: unknown) => {
return pipe(SearchStateRT.decode(value), fold(constant(undefined), identity));
};
type SearchState = t.TypeOf<typeof SearchStateRT>;
const INITIAL_VALUE: SearchState = {
query: { language: 'kuery', query: '' },
panelFilters: [],
filters: [],
};
export type StateAction =
| { type: 'SET_FILTERS'; filters: SearchState['filters'] }
| { type: 'SET_QUERY'; query: SearchState['query'] }
| { type: 'SET_PANEL_FILTERS'; panelFilters: SearchState['panelFilters'] };
const reducer = (state: SearchState, action: StateAction): SearchState => {
switch (action.type) {
case 'SET_FILTERS':
return { ...state, filters: action.filters };
case 'SET_QUERY':
return { ...state, query: action.query };
case 'SET_PANEL_FILTERS':
return { ...state, panelFilters: action.panelFilters };
default:
return state;
}
};
export function useUnifiedSearchUrl() {
const [urlState, setUrlState] = useUrlState<SearchState>({
defaultState: INITIAL_VALUE,
decodeUrlState,
encodeUrlState,
urlStateKey: '_a',
writeDefaultState: true,
});
const [searchState, setSearchState] = useReducer(reducer, urlState);
if (!deepEqual(searchState, urlState)) {
setUrlState(searchState);
}
return { searchState, setSearchState };
}

View file

@ -4,12 +4,83 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { flattenObject } from '@kbn/observability-utils-common/object/flatten_object';
import React from 'react';
import { GroupedInventory } from '../../components/grouped_inventory';
import { UnifiedInventory } from '../../components/unified_inventory';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { EntitiesSummary } from '../../components/entities_summary';
import { EntityGroupAccordion } from '../../components/entity_group_accordion';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useKibana } from '../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { GroupBySelector } from '../../components/group_by_selector';
import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status';
export function InventoryPage() {
const { query } = useInventoryParams('/');
return query.view === 'unified' ? <UnifiedInventory /> : <GroupedInventory />;
const {
services: { inventoryAPIClient },
} = useKibana();
const { refreshSubject$ } = useUnifiedSearchContext();
const {
query: { kuery },
} = useInventoryParams('/');
const { entityTypes } = useInventoryDecodedQueryParams();
const {
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
refresh,
loading,
} = useInventoryAbortableAsync(
({ signal }) => {
const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(entityTypes);
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
params: {
path: {
field: ENTITY_TYPE,
},
query: {
includeEntityTypes: entityTypesOn.length ? JSON.stringify(entityTypesOn) : undefined,
excludeEntityTypes: entityTypesOff.length ? JSON.stringify(entityTypesOff) : undefined,
kuery,
},
},
signal,
});
},
[entityTypes, inventoryAPIClient, kuery]
);
useEffectOnce(() => {
const refreshSubscription = refreshSubject$.subscribe(refresh);
return () => refreshSubscription.unsubscribe();
});
return (
<>
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EntitiesSummary totalEntities={value.entitiesCount} totalGroups={value.groups.length} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<GroupBySelector />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{value.groups.map((group) => {
const groupValue = flattenObject(group)[value.groupBy];
return (
<EntityGroupAccordion
key={`${value.groupBy}-${groupValue}`}
groupBy={value.groupBy}
groupValue={groupValue}
groupCount={group.count}
isLoading={loading}
/>
);
})}
</>
);
}

View file

@ -7,9 +7,9 @@
import { Outlet, createRouter } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { defaultEntitySortField, entityColumnIdsRt } from '../../common/entities';
import { InventoryPageTemplate } from '../components/inventory_page_template';
import { InventoryPage } from '../pages/inventory_page';
import { defaultEntitySortField, entityColumnIdsRt, entityViewRt } from '../../common/entities';
/**
* The array of route definitions to be used when the application
@ -29,10 +29,9 @@ const inventoryRoutes = {
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
view: entityViewRt,
pagination: t.string,
_a: t.string,
controlPanels: t.string,
entityTypes: t.string,
kuery: t.string,
}),
]),
}),
@ -40,7 +39,6 @@ const inventoryRoutes = {
query: {
sortField: defaultEntitySortField,
sortDirection: 'desc',
view: 'grouped',
},
},
children: {

View file

@ -14,6 +14,7 @@ import {
type EntityInventoryViewedParams,
type EntityInventorySearchQuerySubmittedParams,
type EntityViewClickedParams,
type EntityInventoryEntityTypeFilteredParams,
} from './types';
export class TelemetryClient implements ITelemetryClient {
@ -36,4 +37,10 @@ export class TelemetryClient implements ITelemetryClient {
public reportEntityViewClicked = (params: EntityViewClickedParams) => {
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_VIEW_CLICKED, params);
};
public reportEntityInventoryEntityTypeFiltered = (
params: EntityInventoryEntityTypeFilteredParams
) => {
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, params);
};
}

View file

@ -58,6 +58,30 @@ const searchQuerySubmittedEventType: TelemetryEvent = {
},
};
const entityInventoryEntityTypeFilteredEventType: TelemetryEvent = {
eventType: TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
schema: {
include_entity_types: {
type: 'array',
items: {
type: 'keyword',
_meta: {
description: 'List of Entity types used to filter for.',
},
},
},
exclude_entity_types: {
type: 'array',
items: {
type: 'keyword',
_meta: {
description: 'List of Entity types used to filter out.',
},
},
},
},
};
const entityViewClickedEventType: TelemetryEvent = {
eventType: TelemetryEventTypes.ENTITY_VIEW_CLICKED,
schema: {
@ -81,4 +105,5 @@ export const inventoryTelemetryEventBasedTypes = [
entityInventoryViewedEventType,
searchQuerySubmittedEventType,
entityViewClickedEventType,
entityInventoryEntityTypeFilteredEventType,
];

View file

@ -13,6 +13,7 @@ import {
type EntityViewClickedParams,
type EntityInventorySearchQuerySubmittedParams,
TelemetryEventTypes,
EntityInventoryEntityTypeFilteredParams,
} from './types';
describe('TelemetryService', () => {
@ -145,4 +146,24 @@ describe('TelemetryService', () => {
);
});
});
describe('#reportEntityInventoryEntityTypeFiltered', () => {
it('should report entity type filtered with properties', async () => {
const setupParams = getSetupParams();
service.setup(setupParams);
const telemetry = service.start();
const params: EntityInventoryEntityTypeFilteredParams = {
include_entity_types: ['container'],
exclude_entity_types: ['service'],
};
telemetry.reportEntityInventoryEntityTypeFiltered(params);
expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith(
TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
params
);
});
});
});

View file

@ -30,6 +30,11 @@ export interface EntityInventorySearchQuerySubmittedParams {
action: 'submit' | 'refresh';
}
export interface EntityInventoryEntityTypeFilteredParams {
include_entity_types: string[];
exclude_entity_types: string[];
}
export interface EntityViewClickedParams {
entity_type: string;
view_type: 'detail' | 'flyout';
@ -39,7 +44,8 @@ export type TelemetryEventParams =
| InventoryAddDataParams
| EntityInventoryViewedParams
| EntityInventorySearchQuerySubmittedParams
| EntityViewClickedParams;
| EntityViewClickedParams
| EntityInventoryEntityTypeFilteredParams;
export interface ITelemetryClient {
reportInventoryAddData(params: InventoryAddDataParams): void;
@ -48,6 +54,7 @@ export interface ITelemetryClient {
params: EntityInventorySearchQuerySubmittedParams
): void;
reportEntityViewClicked(params: EntityViewClickedParams): void;
reportEntityInventoryEntityTypeFiltered(params: EntityInventoryEntityTypeFilteredParams): void;
}
export enum TelemetryEventTypes {

View file

@ -0,0 +1,16 @@
/*
* 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/rt_types';
export function groupEntityTypesByStatus(entityTypes: EntityType) {
const entityTypesKeys = Object.keys(entityTypes);
return {
entityTypesOn: entityTypesKeys.filter((key) => entityTypes[key] === 'on').sort(),
entityTypesOff: entityTypesKeys.filter((key) => entityTypes[key] === 'off').sort(),
};
}

View file

@ -5,26 +5,43 @@
* 2.0.
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import { kqlQuery } from '@kbn/observability-plugin/server';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
import {
ENTITIES_LATEST_ALIAS,
type EntityGroup,
MAX_NUMBER_OF_ENTITIES,
type EntityGroup,
} from '../../../common/entities';
import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
export async function getEntityGroupsBy({
inventoryEsClient,
field,
esQuery,
kuery,
includeEntityTypes = [],
excludeEntityTypes = [],
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
field: string;
esQuery?: QueryDslQueryContainer;
includeEntityTypes?: string[];
excludeEntityTypes?: string[];
kuery?: string;
}): Promise<EntityGroup[]> {
const from = `FROM ${ENTITIES_LATEST_ALIAS}`;
const where = [getBuiltinEntityDefinitionIdESQLWhereClause()];
const params: ScalarValue[] = [];
if (includeEntityTypes.length) {
where.push(`WHERE ${ENTITY_TYPE} IN (${includeEntityTypes.map(() => '?').join()})`);
params.push(...includeEntityTypes);
}
if (excludeEntityTypes.length) {
where.push(`WHERE ${ENTITY_TYPE} NOT IN (${excludeEntityTypes.map(() => '?').join()})`);
params.push(...excludeEntityTypes);
}
const group = `STATS count = COUNT(*) by ${field}`;
const sort = `SORT ${field} asc`;
@ -35,7 +52,8 @@ export async function getEntityGroupsBy({
'get_entities_groups',
{
query,
filter: esQuery,
filter: { bool: { filter: kqlQuery(kuery) } },
params,
},
{ transform: 'plain' }
);

View file

@ -5,19 +5,20 @@
* 2.0.
*/
import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import { kqlQuery } from '@kbn/observability-plugin/server';
import {
ENTITY_DISPLAY_NAME,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
import { unflattenObject } from '@kbn/observability-utils-common/object/unflatten_object';
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
import {
ENTITIES_LATEST_ALIAS,
InventoryEntity,
MAX_NUMBER_OF_ENTITIES,
type EntityColumnIds,
type InventoryEntity,
} from '../../../common/entities';
import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
@ -35,13 +36,13 @@ export async function getLatestEntities({
inventoryEsClient,
sortDirection,
sortField,
esQuery,
kuery,
entityTypes,
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
sortDirection: 'asc' | 'desc';
sortField: EntityColumnIds;
esQuery?: QueryDslQueryContainer;
kuery?: string;
entityTypes?: string[];
}): Promise<InventoryEntity[]> {
// alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default.
@ -78,7 +79,7 @@ export async function getLatestEntities({
'get_latest_entities',
{
query,
filter: esQuery,
filter: { bool: { filter: kqlQuery(kuery) } },
params,
},
{ transform: 'plain' }

View file

@ -47,7 +47,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
esQuery: jsonRt.pipe(t.UnknownRecord),
kuery: t.string,
entityTypes: jsonRt.pipe(t.array(t.string)),
}),
]),
@ -69,7 +69,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
plugin: `@kbn/${INVENTORY_APP_ID}-plugin`,
});
const { sortDirection, sortField, esQuery, entityTypes } = params.query;
const { sortDirection, sortField, kuery, entityTypes } = params.query;
const [alertsClient, latestEntities] = await Promise.all([
createAlertsClient({ plugins, request }),
@ -77,7 +77,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
inventoryEsClient,
sortDirection,
sortField,
esQuery,
kuery,
entityTypes,
}),
]);
@ -113,7 +113,9 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
t.type({ path: t.type({ field: t.literal(ENTITY_TYPE) }) }),
t.partial({
query: t.partial({
esQuery: jsonRt.pipe(t.UnknownRecord),
includeEntityTypes: jsonRt.pipe(t.array(t.string)),
excludeEntityTypes: jsonRt.pipe(t.array(t.string)),
kuery: t.string,
}),
}),
]),
@ -129,12 +131,14 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
});
const { field } = params.path;
const { esQuery } = params.query ?? {};
const { kuery, includeEntityTypes, excludeEntityTypes } = params.query ?? {};
const groups = await getEntityGroupsBy({
inventoryEsClient,
field,
esQuery,
kuery,
includeEntityTypes,
excludeEntityTypes,
});
const entitiesCount = groups.reduce((acc, group) => acc + group.count, 0);

View file

@ -54,8 +54,6 @@
"@kbn/storybook",
"@kbn/dashboard-plugin",
"@kbn/deeplinks-analytics",
"@kbn/controls-plugin",
"@kbn/securitysolution-io-ts-types",
"@kbn/react-hooks",
"@kbn/observability-utils-common",
"@kbn/observability-utils-browser",