Skip to content

Commit

Permalink
[8.x] [Inventory][ECO] Entities Group By View (#195475) (#198686)
Browse files Browse the repository at this point in the history
# Backport

This will backport the following commits from `main` to `8.x`:
 - [Inventory][ECO] Entities Group By View (#195475) (e65ca78)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Gonçalo Rica Pais da
Silva","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-10-30T13:43:47Z","message":"[Inventory][ECO]
Entities Group By View (#195475)\n\n# Summary\r\n\r\nThis PR introduces
the API and Page for doing grouped views for the\r\nInventory Page.
Alongside the plain list view, the page now by default\r\nshows a
grouped view of entities. In this PR, the only current
supported\r\ngrouping is by Entity
Type.\r\n\r\n\r\nhttps://github.com/user-attachments/assets/a07db592-d6c6-4ec1-a00b-bb469908aa6a\r\n\r\nTests
TBA\r\n\r\n## How to test\r\n\r\n- Navigate to the new Inventory
Page\r\n- By default, the page should load into a grouped view
(Type)\r\n- The page should show all entities currently grouped by their
type.\r\n- If a group has enough entities, pagination navigation should
only\r\napply to the list within the group.\r\n- The plain list view
should function same as before.\r\n- Using the search/filter bar should
function the same with grouped and\r\nlist view.\r\n\r\nCloses
#194740\r\n\r\n---------\r\n\r\nCo-authored-by: Bryce Buchanan
<[email protected]>\r\nCo-authored-by: kibanamachine
<[email protected]>\r\nCo-authored-by:
Elastic Machine
<[email protected]>","sha":"e65ca78d444b3ba324b43ea7ab07d08fc1014c13"},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[]}]
BACKPORT-->

Co-authored-by: Gonçalo Rica Pais da Silva <[email protected]>
  • Loading branch information
cauemarcondes and Bluefinger authored Nov 1, 2024
1 parent 2cb9b9b commit 6ddfb80
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 6ddfb80

Please sign in to comment.