Skip to content

Commit

Permalink
[Inventory][ECO] Entities Group By View (#195475)
Browse files Browse the repository at this point in the history
# 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 <[email protected]>
Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
4 people authored Oct 30, 2024
1 parent b7beae8 commit e65ca78
Show file tree
Hide file tree
Showing 21 changed files with 1,037 additions and 111 deletions.
50 changes: 50 additions & 0 deletions x-pack/plugins/observability_solution/inventory/common/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -67,3 +111,9 @@ export interface Entity {
alertsCount?: number;
[key: string]: any;
}

export type EntityGroup = {
count: number;
} & {
[key: string]: any;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});
Expand All @@ -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');
});
Expand All @@ -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');
});
Expand All @@ -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');
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading

0 comments on commit e65ca78

Please sign in to comment.