[Inventory][ECO] Entities Group By View (#195475)

# Summary

This PR introduces the API and Page for doing grouped views for the
Inventory Page. Alongside the plain list view, the page now by default
shows a grouped view of entities. In this PR, the only current supported
grouping is by Entity Type.


https://github.com/user-attachments/assets/a07db592-d6c6-4ec1-a00b-bb469908aa6a

Tests TBA

## How to test

- Navigate to the new Inventory Page
- By default, the page should load into a grouped view (Type)
- The page should show all entities currently grouped by their type.
- If a group has enough entities, pagination navigation should only
apply to the list within the group.
- The plain list view should function same as before.
- Using the search/filter bar should function the same with grouped and
list view.

Closes #194740

---------

Co-authored-by: Bryce Buchanan <bryce.buchanan@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Gonçalo Rica Pais da Silva 2024-10-30 14:43:47 +01:00 committed by GitHub
parent b7beae8e19
commit e65ca78d44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1037 additions and 111 deletions

View file

@ -13,6 +13,7 @@ import {
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
import { decode, encode } from '@kbn/rison';
import { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
@ -25,6 +26,49 @@ 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;
@ -67,3 +111,9 @@ export interface Entity {
alertsCount?: number;
[key: string]: any;
}
export type EntityGroup = {
count: number;
} & {
[key: string]: any;
};

View file

@ -59,12 +59,38 @@ describe('Home page', () => {
logsSynthtrace.clean();
});
it('Shows inventory page with entities', () => {
it('Shows inventory page with groups & 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('host');
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click();
cy.wait('@getEntities');
cy.contains('service');
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click();
cy.wait('@getEntities');
cy.contains('container');
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').click();
cy.wait('@getEntities');
cy.contains('server1');
cy.contains('synth-node-trace-logs');
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');
@ -79,6 +105,7 @@ describe('Home page', () => {
}).as('getEEMStatus');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.contains('service').click();
cy.contains('synth-node-trace-logs').click();
cy.url().should('include', '/app/apm/services/synth-node-trace-logs/overview');
});
@ -89,6 +116,7 @@ describe('Home page', () => {
}).as('getEEMStatus');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.contains('host').click();
cy.contains('server1').click();
cy.url().should('include', '/app/metrics/detail/host/server1');
});
@ -99,6 +127,7 @@ describe('Home page', () => {
}).as('getEEMStatus');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.contains('container').click();
cy.contains('foo').click();
cy.url().should('include', '/app/metrics/detail/container/foo');
});
@ -107,51 +136,69 @@ describe('Home page', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('GET', '/internal/inventory/entities*').as('getEntitites');
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFilterserviceOption')
.click();
cy.wait('@getEntitites');
cy.wait('@getGroups');
cy.contains('service');
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click();
cy.wait('@getEntities');
cy.get('server1').should('not.exist');
cy.contains('synth-node-trace-logs');
cy.get('foo').should('not.exist');
cy.contains('foo').should('not.exist');
cy.getByTestSubj('inventoryGroup_entity.type_host').should('not.exist');
cy.getByTestSubj('inventoryGroup_entity.type_container').should('not.exist');
});
it('Filters entities by host type', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('GET', '/internal/inventory/entities*').as('getEntitites');
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFilterhostOption')
.click();
cy.wait('@getEntitites');
cy.wait('@getGroups');
cy.contains('host');
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click();
cy.wait('@getEntities');
cy.contains('server1');
cy.get('synth-node-trace-logs').should('not.exist');
cy.get('foo').should('not.exist');
cy.contains('synth-node-trace-logs').should('not.exist');
cy.contains('foo').should('not.exist');
cy.getByTestSubj('inventoryGroup_entity.type_service').should('not.exist');
cy.getByTestSubj('inventoryGroup_entity.type_container').should('not.exist');
});
it('Filters entities by container type', () => {
cy.intercept('GET', '/internal/entities/managed/enablement', {
fixture: 'eem_enabled.json',
}).as('getEEMStatus');
cy.intercept('GET', '/internal/inventory/entities*').as('getEntitites');
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFiltercontainerOption')
.click();
cy.wait('@getEntitites');
cy.get('server1').should('not.exist');
cy.get('synth-node-trace-logs').should('not.exist');
cy.wait('@getGroups');
cy.contains('container');
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('inventoryGroup_entity.type_host').should('not.exist');
cy.getByTestSubj('inventoryGroup_entity.type_service').should('not.exist');
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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

@ -0,0 +1,112 @@
/*
* 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 { EuiPopover, EuiContextMenu, EuiButtonEmpty } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
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';
const GROUP_LABELS: Record<EntityView, string> = {
unified: i18n.translate('xpack.inventory.groupedInventoryPage.noneLabel', {
defaultMessage: 'None',
}),
grouped: 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();
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 = [
{
id: 'firstPanel',
title: i18n.translate('xpack.inventory.groupedInventoryPage.groupSelectorLabel', {
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'),
},
],
},
];
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const button = (
<EuiButtonEmpty
data-test-subj="groupSelectorDropdown"
iconSide="right"
iconSize="s"
iconType="arrowDown"
onClick={onButtonClick}
title={GROUP_LABELS[groupBy]}
size="s"
>
<FormattedMessage
id="xpack.inventory.groupedInventoryPage.groupedByLabel"
defaultMessage={`Group entities by: {grouping}`}
values={{ grouping: GROUP_LABELS[groupBy] }}
/>
</EuiButtonEmpty>
);
return (
<EuiPopover
data-test-subj="inventoryGroupsPopover"
button={button}
closePopover={closePopover}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu
data-test-subj="entitiesGroupByContextMenu"
initialPanelId="firstPanel"
panels={panels}
/>
</EuiPopover>
);
}

View file

@ -0,0 +1,130 @@
/*
* 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 { EuiDataGridSorting } from '@elastic/eui';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useKibana } from '../../hooks/use_kibana';
import { EntitiesGrid } from '../entities_grid';
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';
interface Props {
field: string;
}
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function GroupedEntitiesGrid({ field }: Props) {
const { query } = useInventoryParams('/');
const { sortField, sortDirection, kuery, pagination: paginationQuery } = query;
const inventoryRoute = useInventoryRouter();
let pagination: EntityPagination | undefined = {};
try {
pagination = paginationDecoder(paginationQuery);
} catch (error) {
inventoryRoute.push('/', {
path: {},
query: {
sortField,
sortDirection,
kuery,
pagination: undefined,
},
});
window.location.reload();
}
const pageIndex = pagination?.[field] ?? 0;
const { refreshSubject$ } = useInventorySearchBarContext();
const {
services: { inventoryAPIClient },
} = useKibana();
const {
value = { entities: [] },
loading,
refresh,
} = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
entityTypes: field?.length ? JSON.stringify([field]) : undefined,
kuery,
},
},
signal,
});
},
[field, inventoryAPIClient, kuery, sortDirection, sortField]
);
useEffectOnce(() => {
const refreshSubscription = refreshSubject$.subscribe(refresh);
return () => refreshSubscription.unsubscribe();
});
function handlePageChange(nextPage: number) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
pagination: entityPaginationRt.encode({
...pagination,
[field]: nextPage,
}),
},
});
}
function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
sortField: sorting.id as EntityColumnIds,
sortDirection: sorting.direction,
},
});
}
function handleTypeFilter(type: string) {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: {
...rest,
// Override the current entity types
entityTypes: [type],
},
});
}
return (
<EntitiesGrid
entities={value.entities}
loading={loading}
sortDirection={sortDirection}
sortField={sortField}
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
);
}

View file

@ -0,0 +1,68 @@
/*
* 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 { InventoryGroupAccordion } from './inventory_group_accordion';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useKibana } from '../../hooks/use_kibana';
import { InventorySummary } from './inventory_summary';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
export function GroupedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { query } = useInventoryParams('/');
const { kuery, entityTypes } = query;
const { refreshSubject$ } = useInventorySearchBarContext();
const {
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
refresh,
loading,
} = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
params: {
path: {
field: ENTITY_TYPE,
},
query: {
kuery,
entityTypes: entityTypes?.length ? JSON.stringify(entityTypes) : undefined,
},
},
signal,
});
},
[entityTypes, inventoryAPIClient, kuery]
);
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) => (
<InventoryGroupAccordion
key={`${value.groupBy}-${group[value.groupBy]}`}
group={group}
groupBy={value.groupBy}
isLoading={loading}
/>
))}
</>
);
}

View file

@ -0,0 +1,34 @@
/*
* 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, within } from '@testing-library/react';
import { InventoryGroupAccordion } from './inventory_group_accordion';
describe('Grouped Inventory Accordion', () => {
it('renders with correct values', () => {
const props = {
groupBy: 'entity.type',
groups: [
{
count: 5999,
'entity.type': 'host',
},
{
count: 2001,
'entity.type': 'service',
},
],
};
render(<InventoryGroupAccordion group={props.groups[0]} groupBy={props.groupBy} />);
expect(screen.getByText(props.groups[0]['entity.type'])).toBeInTheDocument();
const container = screen.getByTestId('inventoryPanelBadgeEntitiesCount_entity.type_host');
expect(within(container).getByText('Entities:')).toBeInTheDocument();
expect(within(container).getByText(props.groups[0].count)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,87 @@
/*
* 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, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import { GroupedEntitiesGrid } from './grouped_entities_grid';
import type { EntityGroup } from '../../../common/entities';
import { InventoryPanelBadge } from './inventory_panel_badge';
const ENTITIES_COUNT_BADGE = i18n.translate(
'xpack.inventory.inventoryGroupPanel.entitiesBadgeLabel',
{ defaultMessage: 'Entities' }
);
export interface InventoryGroupAccordionProps {
group: EntityGroup;
groupBy: string;
isLoading?: boolean;
}
export function InventoryGroupAccordion({
group,
groupBy,
isLoading,
}: InventoryGroupAccordionProps) {
const { euiTheme } = useEuiTheme();
const field = group[groupBy];
const [open, setOpen] = useState(false);
const onToggle = useCallback(() => {
setOpen((opened) => !opened);
}, []);
return (
<>
<EuiPanel
hasBorder
hasShadow={false}
css={css`
padding: ${euiTheme.size.xs} ${euiTheme.size.base};
`}
>
<EuiAccordion
data-test-subj={`inventoryGroup_${groupBy}_${field}`}
id={`inventory-group-${groupBy}-${field}`}
buttonContent={
<EuiTitle size="xs">
<h4 data-test-subj={`inventoryGroupTitle_${groupBy}_${field}`}>{field}</h4>
</EuiTitle>
}
buttonElement="div"
extraAction={
<InventoryPanelBadge
data-test-subj={`inventoryPanelBadgeEntitiesCount_${groupBy}_${field}`}
name={ENTITIES_COUNT_BADGE}
value={group.count}
/>
}
buttonProps={{ paddingSize: 'm' }}
paddingSize="none"
onToggle={onToggle}
isLoading={isLoading}
/>
</EuiPanel>
{open && (
<EuiPanel
css={css`
margin: 0 ${euiTheme.size.s};
border-top: none;
border-radius: 0 0 ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium};
`}
hasBorder
hasShadow={false}
paddingSize="m"
>
<GroupedEntitiesGrid field={field} />
</EuiPanel>
)}
<EuiSpacer size="s" />
</>
);
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import React from 'react';
export function InventoryPanelBadge({
name,
value,
'data-test-subj': dataTestSubj,
}: {
name: string;
'data-test-subj'?: string;
value: string | number;
}) {
return (
<EuiFlexGroup data-test-subj={dataTestSubj} gutterSize="s" alignItems="center">
<EuiFlexItem>
<EuiText size="xs">
<strong>{name}:</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiBadge color="hollow">{value}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,43 @@
/*
* 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 { 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</>,
}));
function MockEnvWrapper({ children }: { children?: React.ReactNode }) {
return (
<I18nProvider>
<EuiThemeProvider>{children}</EuiThemeProvider>
</I18nProvider>
);
}
describe('InventorySummary', () => {
it('renders the total entities without any group totals', () => {
render(<InventorySummary 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 });
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 });
expect(screen.queryByTestId('inventorySummaryEntitiesTotal')).not.toBeInTheDocument();
expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,69 @@
/*
* 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

@ -0,0 +1,38 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { createMemoryHistory } from 'history';
import { RouterProvider } from '@kbn/typed-react-router-config';
import { I18nProvider } from '@kbn/i18n-react';
import { EuiThemeProvider } from '@elastic/eui';
import { getMockInventoryContext } from '../../../../.storybook/get_mock_inventory_context';
import { inventoryRouter } from '../../../routes/config';
import { InventoryContextProvider } from '../../../context/inventory_context_provider';
export function InventoryComponentWrapperMock({ children }: React.PropsWithChildren<{}>) {
const context = getMockInventoryContext();
const KibanaReactContext = createKibanaReactContext(context as unknown as Partial<CoreStart>);
const history = createMemoryHistory({
initialEntries: ['/'],
});
return (
<I18nProvider>
<EuiThemeProvider>
<KibanaReactContext.Provider>
<InventoryContextProvider context={getMockInventoryContext()}>
<RouterProvider router={inventoryRouter} history={history}>
{children}
</RouterProvider>
</InventoryContextProvider>
</KibanaReactContext.Provider>
</EuiThemeProvider>
</I18nProvider>
);
}

View file

@ -0,0 +1,131 @@
/*
* 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 React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import {
type EntityColumnIds,
entityPaginationRt,
type EntityPagination,
} from '../../../common/entities';
import { EntitiesGrid } from '../entities_grid';
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 { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { InventorySummary } from './inventory_summary';
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function UnifiedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { refreshSubject$ } = useInventorySearchBarContext();
const { query } = useInventoryParams('/');
const { sortDirection, sortField, kuery, entityTypes, pagination: paginationQuery } = query;
let pagination: EntityPagination | undefined = {};
const inventoryRoute = useInventoryRouter();
try {
pagination = paginationDecoder(paginationQuery);
} catch (error) {
inventoryRoute.push('/', {
path: {},
query: {
sortField,
sortDirection,
kuery,
pagination: undefined,
},
});
window.location.reload();
}
const pageIndex = pagination?.unified ?? 0;
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,
});
},
[entityTypes, inventoryAPIClient, kuery, sortDirection, sortField]
);
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,
},
});
}
function handleTypeFilter(type: string) {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: {
...rest,
// Override the current entity types
entityTypes: [type],
},
});
}
return (
<>
<InventorySummary />
<EntitiesGrid
entities={value.entities}
loading={loading}
sortDirection={sortDirection}
sortField={sortField}
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
</>
);
}

View file

@ -19,7 +19,7 @@ import { DiscoverButton } from './discover_button';
import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback';
export function SearchBar() {
const { searchBarContentSubject$ } = useInventorySearchBarContext();
const { searchBarContentSubject$, refreshSubject$ } = useInventorySearchBarContext();
const {
services: {
unifiedSearch,
@ -84,7 +84,7 @@ export function SearchBar() {
const handleEntityTypesChange = useCallback(
(nextEntityTypes: string[]) => {
searchBarContentSubject$.next({ kuery, entityTypes: nextEntityTypes, refresh: false });
searchBarContentSubject$.next({ kuery, entityTypes: nextEntityTypes });
registerEntityTypeFilteredEvent({ filterEntityTypes: nextEntityTypes, filterKuery: kuery });
},
[kuery, registerEntityTypeFilteredEvent, searchBarContentSubject$]
@ -95,7 +95,6 @@ export function SearchBar() {
searchBarContentSubject$.next({
kuery: query?.query as string,
entityTypes,
refresh: !isUpdate,
});
registerSearchSubmittedEvent({
@ -103,8 +102,12 @@ export function SearchBar() {
searchEntityTypes: entityTypes,
searchIsUpdate: isUpdate,
});
if (!isUpdate) {
refreshSubject$.next();
}
},
[entityTypes, registerSearchSubmittedEvent, searchBarContentSubject$]
[entityTypes, registerSearchSubmittedEvent, searchBarContentSubject$, refreshSubject$]
);
return (

View file

@ -11,17 +11,20 @@ interface InventorySearchBarContextType {
searchBarContentSubject$: Subject<{
kuery?: string;
entityTypes?: string[];
refresh: boolean;
}>;
refreshSubject$: Subject<void>;
}
const InventorySearchBarContext = createContext<InventorySearchBarContextType>({
searchBarContentSubject$: new Subject(),
refreshSubject$: new Subject(),
});
export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) {
return (
<InventorySearchBarContext.Provider value={{ searchBarContentSubject$: new Subject() }}>
<InventorySearchBarContext.Provider
value={{ searchBarContentSubject$: new Subject(), refreshSubject$: new Subject() }}
>
{children}
</InventorySearchBarContext.Provider>
);

View file

@ -4,105 +4,36 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiDataGridSorting } from '@elastic/eui';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { EntityColumnIds } from '../../../common/entities';
import { EntitiesGrid } from '../../components/entities_grid';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import React, { useEffect } from 'react';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import { useKibana } from '../../hooks/use_kibana';
import { UnifiedInventory } from '../../components/grouped_inventory/unified_inventory';
import { GroupedInventory } from '../../components/grouped_inventory';
export function InventoryPage() {
const { searchBarContentSubject$ } = useInventorySearchBarContext();
const {
services: { inventoryAPIClient },
} = useKibana();
const { query } = useInventoryParams('/');
const { sortDirection, sortField, pageIndex, kuery, entityTypes } = query;
const inventoryRoute = useInventoryRouter();
const { query } = useInventoryParams('/');
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,
});
},
[entityTypes, inventoryAPIClient, kuery, sortDirection, sortField]
);
useEffectOnce(() => {
useEffect(() => {
const searchBarContentSubscription = searchBarContentSubject$.subscribe(
({ refresh: isRefresh, ...queryParams }) => {
if (isRefresh) {
refresh();
} else {
inventoryRoute.push('/', {
path: {},
query: { ...query, ...queryParams },
});
}
({ ...queryParams }) => {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: { ...rest, ...queryParams },
});
}
);
return () => {
searchBarContentSubscription.unsubscribe();
};
});
// If query has updated, the inventoryRoute state is also updated
// as well, so we only need to track changes on query.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, searchBarContentSubject$]);
function handlePageChange(nextPage: number) {
inventoryRoute.push('/', {
path: {},
query: { ...query, pageIndex: nextPage },
});
}
function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
sortField: sorting.id as EntityColumnIds,
sortDirection: sorting.direction,
},
});
}
function handleTypeFilter(entityType: string) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
// Override the current entity types
entityTypes: [entityType],
},
});
}
return (
<EntitiesGrid
entities={value.entities}
loading={loading}
sortDirection={sortDirection}
sortField={sortField}
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
);
return query.view === 'unified' ? <UnifiedInventory /> : <GroupedInventory />;
}

View file

@ -4,13 +4,17 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { toNumberRt } from '@kbn/io-ts-utils';
import { Outlet, createRouter } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { InventoryPageTemplate } from '../components/inventory_page_template';
import { InventoryPage } from '../pages/inventory_page';
import { defaultEntitySortField, entityTypesRt, entityColumnIdsRt } from '../../common/entities';
import {
defaultEntitySortField,
entityTypesRt,
entityColumnIdsRt,
entityViewRt,
} from '../../common/entities';
/**
* The array of route definitions to be used when the application
@ -28,11 +32,12 @@ const inventoryRoutes = {
t.type({
sortField: entityColumnIdsRt,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
pageIndex: toNumberRt,
}),
t.partial({
entityTypes: entityTypesRt,
kuery: t.string,
view: entityViewRt,
pagination: t.string,
}),
]),
}),
@ -40,7 +45,7 @@ const inventoryRoutes = {
query: {
sortField: defaultEntitySortField,
sortDirection: 'desc',
pageIndex: '0',
view: 'grouped',
},
},
children: {

View file

@ -0,0 +1,58 @@
/*
* 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 { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import {
ENTITIES_LATEST_ALIAS,
type EntityGroup,
MAX_NUMBER_OF_ENTITIES,
} from '../../../common/entities';
import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
export async function getEntityGroupsBy({
inventoryEsClient,
field,
kuery,
entityTypes,
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
field: string;
kuery?: string;
entityTypes?: string[];
}) {
const from = `FROM ${ENTITIES_LATEST_ALIAS}`;
const where = [getBuiltinEntityDefinitionIdESQLWhereClause()];
const params: ScalarValue[] = [];
if (entityTypes) {
where.push(`WHERE ${ENTITY_TYPE} IN (${entityTypes.map(() => '?').join()})`);
params.push(...entityTypes);
}
// STATS doesn't support parameterisation.
const group = `STATS count = COUNT(*) by ${field}`;
const sort = `SORT ${field} asc`;
// LIMIT doesn't support parameterisation.
const limit = `LIMIT ${MAX_NUMBER_OF_ENTITIES}`;
const query = [from, ...where, group, sort, limit].join(' | ');
const groups = await inventoryEsClient.esql('get_entities_groups', {
query,
filter: {
bool: {
filter: kqlQuery(kuery),
},
},
params,
});
return esqlResultToPlainObjects<EntityGroup>(groups);
}

View file

@ -40,7 +40,7 @@ export async function getLatestEntities({
if (entityTypes) {
where.push(`WHERE ${ENTITY_TYPE} IN (${entityTypes.map(() => '?').join()})`);
params.push(...entityTypes.map((entityType) => entityType));
params.push(...entityTypes);
}
const sort = `SORT ${entitiesSortField} ${sortDirection}`;

View file

@ -7,6 +7,7 @@
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 { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import * as t from 'io-ts';
import { orderBy } from 'lodash';
import { joinByKey } from '@kbn/observability-utils/array/join_by_key';
@ -17,6 +18,7 @@ import { getLatestEntities } from './get_latest_entities';
import { createAlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client';
import { getLatestEntitiesAlerts } from './get_latest_entities_alerts';
import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
import { getEntityGroupsBy } from './get_entity_groups';
export const getEntityTypesRoute = createInventoryServerRoute({
endpoint: 'GET /internal/inventory/entities/types',
@ -106,7 +108,46 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
},
});
export const groupEntitiesByRoute = createInventoryServerRoute({
endpoint: 'GET /internal/inventory/entities/group_by/{field}',
params: t.intersection([
t.type({ path: t.type({ field: t.literal(ENTITY_TYPE) }) }),
t.partial({
query: t.partial({
kuery: t.string,
entityTypes: jsonRt.pipe(t.array(t.string)),
}),
}),
]),
options: {
tags: ['access:inventory'],
},
handler: async ({ params, context, logger }) => {
const coreContext = await context.core;
const inventoryEsClient = createObservabilityEsClient({
client: coreContext.elasticsearch.client.asCurrentUser,
logger,
plugin: `@kbn/${INVENTORY_APP_ID}-plugin`,
});
const { field } = params.path;
const { kuery, entityTypes } = params.query ?? {};
const groups = await getEntityGroupsBy({
inventoryEsClient,
field,
kuery,
entityTypes,
});
const entitiesCount = groups.reduce((acc, group) => acc + group.count, 0);
return { groupBy: field, groups, entitiesCount };
},
});
export const entitiesRoutes = {
...listLatestEntitiesRoute,
...getEntityTypesRoute,
...groupEntitiesByRoute,
};

View file

@ -52,6 +52,6 @@
"@kbn/rule-data-utils",
"@kbn/spaces-plugin",
"@kbn/cloud-plugin",
"@kbn/storybook"
"@kbn/storybook",
]
}