mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
b7beae8e19
commit
e65ca78d44
21 changed files with 1037 additions and 111 deletions
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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}`;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -52,6 +52,6 @@
|
|||
"@kbn/rule-data-utils",
|
||||
"@kbn/spaces-plugin",
|
||||
"@kbn/cloud-plugin",
|
||||
"@kbn/storybook"
|
||||
"@kbn/storybook",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue