diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index d8a056074e339..e5bd12d252767 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -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; +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 | 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; + +export type EntityPagination = t.TypeOf; + 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; +}; diff --git a/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts b/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts index c18f8866475ab..3ca60464d571b 100644 --- a/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts +++ b/x-pack/plugins/observability_solution/inventory/e2e/cypress/e2e/home.cy.ts @@ -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'); }); }); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.test.tsx new file mode 100644 index 0000000000000..23cbb5b43c43b --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.test.tsx @@ -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( + + + + ); + }); + 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(); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.tsx new file mode 100644 index 0000000000000..95264f3c81303 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.tsx @@ -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 = { + 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 = ( + + + + ); + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/grouped_entities_grid.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/grouped_entities_grid.tsx new file mode 100644 index 0000000000000..d005a001999d5 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/grouped_entities_grid.tsx @@ -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 ( + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx new file mode 100644 index 0000000000000..b376200495e43 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx @@ -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 ( + <> + + + {value.groups.map((group) => ( + + ))} + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.test.tsx new file mode 100644 index 0000000000000..2cddbb8e46d79 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.tsx new file mode 100644 index 0000000000000..4c5d34e5a028f --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.tsx @@ -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 ( + <> + + +

{field}

+ + } + buttonElement="div" + extraAction={ + + } + buttonProps={{ paddingSize: 'm' }} + paddingSize="none" + onToggle={onToggle} + isLoading={isLoading} + /> +
+ {open && ( + + + + )} + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_panel_badge.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_panel_badge.tsx new file mode 100644 index 0000000000000..43db1c39154bc --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_panel_badge.tsx @@ -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 ( + + + + {name}: + + + + {value} + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.test.tsx new file mode 100644 index 0000000000000..63583e60b0edd --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.test.tsx @@ -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 ( + + {children} + + ); +} + +describe('InventorySummary', () => { + it('renders the total entities without any group totals', () => { + render(, { wrapper: MockEnvWrapper }); + expect(screen.getByText('10 Entities')).toBeInTheDocument(); + expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument(); + }); + it('renders the total entities with group totals', () => { + render(, { 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(, { wrapper: MockEnvWrapper }); + expect(screen.queryByTestId('inventorySummaryEntitiesTotal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.tsx new file mode 100644 index 0000000000000..55697790c4ee9 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.tsx @@ -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 ( + + + + {totalEntities !== undefined && ( + + + + + + )} + {isGrouped ? ( + + + + + + ) : null} + + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/mock/inventory_component_wrapper_mock.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/mock/inventory_component_wrapper_mock.tsx new file mode 100644 index 0000000000000..08c8e93aadda8 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/mock/inventory_component_wrapper_mock.tsx @@ -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); + const history = createMemoryHistory({ + initialEntries: ['/'], + }); + return ( + + + + + + {children} + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/unified_inventory.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/unified_inventory.tsx new file mode 100644 index 0000000000000..05f7437a32c4b --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/unified_inventory.tsx @@ -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 ( + <> + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx index 4e945dd9a1cad..2fd450aab30dd 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx @@ -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 ( diff --git a/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx b/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx index fbb51c4f0d7e7..eb5a2a057e529 100644 --- a/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/context/inventory_search_bar_context_provider/index.tsx @@ -11,17 +11,20 @@ interface InventorySearchBarContextType { searchBarContentSubject$: Subject<{ kuery?: string; entityTypes?: string[]; - refresh: boolean; }>; + refreshSubject$: Subject; } const InventorySearchBarContext = createContext({ searchBarContentSubject$: new Subject(), + refreshSubject$: new Subject(), }); export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) { return ( - + {children} ); diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 00dfb9e24d2dd..03f8b6475175a 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -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(); }; - }); - - 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], - }, - }); - } + // 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$]); - return ( - - ); + return query.view === 'unified' ? : ; } diff --git a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx index dc7ba13451e02..36a15c5ae542c 100644 --- a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx @@ -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: { diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts new file mode 100644 index 0000000000000..b61f245f1aaf2 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts @@ -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(groups); +} diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index 4fb3b930beace..c95a488ad49dd 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -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}`; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts index 67b3803dd98de..88d6cb68ee214 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts @@ -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, }; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index 67de9919c6324..d27d170b0990e 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -52,6 +52,6 @@ "@kbn/rule-data-utils", "@kbn/spaces-plugin", "@kbn/cloud-plugin", - "@kbn/storybook" + "@kbn/storybook", ] }