diff --git a/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_kibana_client.ts b/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_kibana_client.ts index 677b69f0ae759..cd683f2831bef 100644 --- a/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_kibana_client.ts +++ b/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_kibana_client.ts @@ -34,7 +34,7 @@ export class EntitiesSynthtraceKibanaClient { }); const entityDefinition: EntityDefinitionResponse = await response.json(); - const hasEntityDefinitionsInstalled = entityDefinition.definitions.find( + const hasEntityDefinitionsInstalled = entityDefinition.definitions?.find( (definition) => definition.type === 'service' )?.state.installed; diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index 65fd8a4ffbd7a..b006fa0c7f6d8 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -5,8 +5,6 @@ * 2.0. */ import { ENTITY_LATEST, entitiesAliasPattern, type EntityMetadata } from '@kbn/entities-schema'; -import { decode, encode } from '@kbn/rison'; -import { isRight } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; export const entityColumnIdsRt = t.union([ @@ -19,49 +17,6 @@ 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; diff --git a/x-pack/plugins/observability_solution/inventory/common/rt_types.ts b/x-pack/plugins/observability_solution/inventory/common/rt_types.ts new file mode 100644 index 0000000000000..17496d672e610 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/rt_types.ts @@ -0,0 +1,60 @@ +/* + * 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 { decode, encode } from '@kbn/rison'; +import { isRight } from 'fp-ts/lib/Either'; +import * as t from 'io-ts'; + +const validate = (validationRt: t.Any) => (input: unknown, context: t.Context) => { + switch (typeof input) { + case 'string': { + try { + const decoded = decode(input); + const validation = validationRt.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 = validationRt.decode(input); + + if (isRight(validation)) { + return t.success(validation.right); + } + + return t.failure(input, context); + } + } +}; + +const entityTypeCheckOptions = t.union([t.literal('on'), t.literal('off'), t.literal('mixed')]); +export type EntityTypeCheckOptions = t.TypeOf; + +const entityTypeRt = t.record(t.string, entityTypeCheckOptions); +export type EntityType = t.TypeOf; +export const entityTypesRt = new t.Type< + Record | undefined, + string, + unknown +>('entityTypesRt', entityTypeRt.is, validate(entityTypeRt), (o) => encode(o)); + +const paginationRt = t.record(t.string, t.number); +export type EntityPagination = t.TypeOf; +export const entityPaginationRt = new t.Type | undefined, string, unknown>( + 'entityPaginationRt', + paginationRt.is, + validate(paginationRt), + (o) => encode(o) +); 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 61a55bab3258f..c2e7f1232e6aa 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 @@ -80,25 +80,6 @@ describe('Home page', () => { 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'); - cy.contains('service'); - cy.contains('foo'); - cy.contains('container'); - }); - it('Navigates to apm when clicking on a service type entity', () => { cy.intercept('GET', '/internal/entities/managed/enablement', { fixture: 'eem_enabled.json', @@ -148,69 +129,69 @@ describe('Home page', () => { cy.intercept('GET', '/internal/entities/managed/enablement', { fixture: 'eem_enabled.json', }).as('getEEMStatus'); - cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as( - 'entityTypeControlGroupOptions' - ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); + cy.wait('@getEntitiesTypes'); cy.wait('@getEEMStatus'); - cy.getByTestSubj('optionsList-control-entity.type').click(); - cy.wait('@entityTypeControlGroupOptions'); - cy.getByTestSubj('optionsList-control-selection-service').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click(); cy.wait('@getGroups'); cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click(); cy.wait('@getEntities'); cy.get('server1').should('not.exist'); cy.contains('synth-node-trace-logs'); cy.contains('foo').should('not.exist'); + cy.getByTestSubj('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click(); + cy.getByTestSubj('inventoryGroupTitle_entity.type_service').should('not.exist'); }); it('Filters entities by host type', () => { cy.intercept('GET', '/internal/entities/managed/enablement', { fixture: 'eem_enabled.json', }).as('getEEMStatus'); - cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as( - 'entityTypeControlGroupOptions' - ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); + cy.wait('@getEntitiesTypes'); cy.wait('@getEEMStatus'); - cy.getByTestSubj('optionsList-control-entity.type').click(); - cy.wait('@entityTypeControlGroupOptions'); - cy.getByTestSubj('optionsList-control-selection-host').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click(); cy.wait('@getGroups'); cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click(); cy.wait('@getEntities'); cy.contains('server1'); cy.contains('synth-node-trace-logs').should('not.exist'); cy.contains('foo').should('not.exist'); + cy.getByTestSubj('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click(); + cy.getByTestSubj('inventoryGroupTitle_entity.type_host').should('not.exist'); }); it('Filters entities by container type', () => { cy.intercept('GET', '/internal/entities/managed/enablement', { fixture: 'eem_enabled.json', }).as('getEEMStatus'); - cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as( - 'entityTypeControlGroupOptions' - ); cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities'); cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes'); cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups'); cy.visitKibana('/app/inventory'); + cy.wait('@getEntitiesTypes'); cy.wait('@getEEMStatus'); - cy.getByTestSubj('optionsList-control-entity.type').click(); - cy.wait('@entityTypeControlGroupOptions'); - cy.getByTestSubj('optionsList-control-selection-container').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click(); cy.wait('@getGroups'); 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('entityTypes_multiSelect_filter').click(); + cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click(); + cy.getByTestSubj('inventoryGroupTitle_entity.type_container').should('not.exist'); }); it('Navigates to discover with actions button in the entities list', () => { diff --git a/x-pack/plugins/observability_solution/inventory/e2e/cypress/support/commands.ts b/x-pack/plugins/observability_solution/inventory/e2e/cypress/support/commands.ts index 6694b50ce9c70..c3462f8b6ff18 100644 --- a/x-pack/plugins/observability_solution/inventory/e2e/cypress/support/commands.ts +++ b/x-pack/plugins/observability_solution/inventory/e2e/cypress/support/commands.ts @@ -27,23 +27,31 @@ Cypress.Commands.add('loginAsSuperUser', () => { Cypress.Commands.add( 'loginAs', ({ username, password }: { username: string; password: string }) => { - const kibanaUrl = Cypress.env('KIBANA_URL'); - cy.log(`Logging in as ${username} on ${kibanaUrl}`); - cy.visit('/'); - cy.request({ - log: true, - method: 'POST', - url: `${kibanaUrl}/internal/security/login`, - body: { - providerType: 'basic', - providerName: 'basic', - currentURL: `${kibanaUrl}/login`, - params: { username, password }, + cy.session( + username, + () => { + const kibanaUrl = Cypress.env('KIBANA_URL'); + cy.log(`Logging in as ${username} on ${kibanaUrl}`); + cy.visit('/'); + cy.request({ + log: true, + method: 'POST', + url: `${kibanaUrl}/internal/security/login`, + body: { + providerType: 'basic', + providerName: 'basic', + currentURL: `${kibanaUrl}/login`, + params: { username, password }, + }, + headers: { + 'kbn-xsrf': 'e2e_test', + }, + }); + cy.visit('/'); }, - headers: { - 'kbn-xsrf': 'e2e_test', - }, - }); - cy.visit('/'); + { + cacheAcrossSpecs: true, + } + ); } ); diff --git a/x-pack/plugins/observability_solution/inventory/kibana.jsonc b/x-pack/plugins/observability_solution/inventory/kibana.jsonc index e6e7c5f2fa2f8..e7cc398c9c655 100644 --- a/x-pack/plugins/observability_solution/inventory/kibana.jsonc +++ b/x-pack/plugins/observability_solution/inventory/kibana.jsonc @@ -21,7 +21,7 @@ "ruleRegistry", "share" ], - "requiredBundles": ["kibanaReact","controls"], + "requiredBundles": ["kibanaReact"], "optionalPlugins": ["spaces", "cloud"], "extraPublicDirs": [] } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx index f3c518ef49b16..dfde850b36b60 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx @@ -27,7 +27,7 @@ describe('BadgeFilterWithPopover', () => { }); it('opens the popover when the badge is clicked', () => { - render(); + render(); expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument(); fireEvent.click(screen.getByText(value)); expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument(); @@ -35,9 +35,25 @@ describe('BadgeFilterWithPopover', () => { }); it('copies value to clipboard when the "Copy value" button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByText(value)); fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton')); expect(copyToClipboard).toHaveBeenCalledWith(value); }); + + it('Filter for an entity', () => { + const handleFilter = jest.fn(); + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton')); + expect(handleFilter).toHaveBeenCalledWith(value, 'on'); + }); + + it('Filter out an entity', () => { + const handleFilter = jest.fn(); + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterOutButton')); + expect(handleFilter).toHaveBeenCalledWith(value, 'off'); + }); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx index 83e0bb02e6d8d..ea85754dcbf75 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx @@ -18,19 +18,18 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import React, { useState } from 'react'; -import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; +import type { EntityTypeCheckOptions } from '../../../common/rt_types'; interface Props { field: string; value: string; + onFilter: (value: string, checked: EntityTypeCheckOptions) => void; } -export function BadgeFilterWithPopover({ field, value }: Props) { +export function BadgeFilterWithPopover({ field, value, onFilter }: Props) { const [isOpen, setIsOpen] = useState(false); const theme = useEuiTheme(); - const { addFilter } = useUnifiedSearchContext(); return ( { - addFilter({ fieldName: ENTITY_TYPE, operation: '+', value }); + onFilter(value, 'on'); }} > {i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', { @@ -92,10 +91,10 @@ export function BadgeFilterWithPopover({ field, value }: Props) { { - addFilter({ fieldName: ENTITY_TYPE, operation: '-', value }); + onFilter(value, 'off'); }} > {i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', { diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx index ae80bf09ecae2..b5e9287a836dd 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -77,6 +77,7 @@ export const Grid: Story = (args) => { onChangePage={setPageIndex} onChangeSort={setSort} pageIndex={pageIndex} + onFilterByType={() => {}} /> @@ -99,6 +100,7 @@ export const EmptyGrid: Story = (args) => { onChangePage={setPageIndex} onChangeSort={setSort} pageIndex={pageIndex} + onFilterByType={() => {}} /> ); }; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index 541af76350723..b26676494833e 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -22,6 +22,7 @@ import { getColumns } from './grid_columns'; import { AlertsBadge } from '../alerts_badge/alerts_badge'; import { EntityName } from './entity_name'; import { EntityActions } from '../entity_actions'; +import { type EntityTypeCheckOptions } from '../../../common/rt_types'; interface Props { loading: boolean; @@ -31,6 +32,7 @@ interface Props { pageIndex: number; onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void; onChangePage: (nextPage: number) => void; + onFilterByType: (value: string, checked: EntityTypeCheckOptions) => void; } const PAGE_SIZE = 20; @@ -43,6 +45,7 @@ export function EntitiesGrid({ pageIndex, onChangePage, onChangeSort, + onFilterByType, }: Props) { const [showActions, setShowActions] = useState(true); @@ -84,7 +87,13 @@ export function EntitiesGrid({ return entity?.alertsCount ? : null; case 'entityType': - return ; + return ( + + ); case 'entityLastSeenTimestamp': return ( @@ -120,7 +129,7 @@ export function EntitiesGrid({ return null; } }, - [entities] + [entities, onFilterByType] ); if (loading) { 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/entities_summary/entities_summary.test.tsx similarity index 72% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.test.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entities_summary/entities_summary.test.tsx index 63583e60b0edd..9f4a5df76e0d9 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.test.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_summary/entities_summary.test.tsx @@ -9,12 +9,7 @@ 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, -})); +import { EntitiesSummary } from '.'; function MockEnvWrapper({ children }: { children?: React.ReactNode }) { return ( @@ -24,19 +19,19 @@ function MockEnvWrapper({ children }: { children?: React.ReactNode }) { ); } -describe('InventorySummary', () => { +describe('EntitiesSummary', () => { it('renders the total entities without any group totals', () => { - render(, { wrapper: MockEnvWrapper }); + 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 }); + 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 }); + 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/entities_summary/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_summary/index.tsx new file mode 100644 index 0000000000000..8388ad64407ac --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_summary/index.tsx @@ -0,0 +1,61 @@ +/* + * 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'; + +export function EntitiesSummary({ + 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/inventory_panel_badge.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/entity_count_badge.tsx similarity index 95% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_panel_badge.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/entity_count_badge.tsx index 43db1c39154bc..a0c9a2a18c4a6 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_panel_badge.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/entity_count_badge.tsx @@ -7,7 +7,7 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import React from 'react'; -export function InventoryPanelBadge({ +export function EntityCountBadge({ name, value, 'data-test-subj': dataTestSubj, 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/entity_group_accordion/entity_group_accordion.test.tsx similarity index 80% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.test.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/entity_group_accordion.test.tsx index bf0b7064033f4..747124808df2e 100644 --- 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/entity_group_accordion/entity_group_accordion.test.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; +import { EntityGroupAccordion } from '.'; -import { InventoryGroupAccordion } from './inventory_group_accordion'; - -describe('Grouped Inventory Accordion', () => { +describe('EntityGroupAccordion', () => { it('renders with correct values', () => { const props = { groupBy: 'entity.type', @@ -26,14 +25,14 @@ describe('Grouped Inventory Accordion', () => { ], }; render( - ); expect(screen.getByText(props.groups[0]['entity.type'])).toBeInTheDocument(); - const container = screen.getByTestId('inventoryPanelBadgeEntitiesCount_entity.type_host'); + const container = screen.getByTestId('entityCountBadge_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/grouped_entities_grid.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/grouped_entities_grid.tsx similarity index 67% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/grouped_entities_grid.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/grouped_entities_grid.tsx index 911e997401023..5dde32cbb4aac 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/grouped_entities_grid.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/grouped_entities_grid.tsx @@ -5,15 +5,16 @@ * 2.0. */ import { EuiDataGridSorting } from '@elastic/eui'; -import { decodeOrThrow } from '@kbn/io-ts-utils'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { type EntityColumnIds } from '../../../common/entities'; import { + type EntityTypeCheckOptions, entityPaginationRt, - type EntityColumnIds, - type EntityPagination, -} from '../../../common/entities'; + entityTypesRt, +} from '../../../common/rt_types'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; +import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useInventoryRouter } from '../../hooks/use_inventory_router'; import { useKibana } from '../../hooks/use_kibana'; @@ -24,29 +25,14 @@ interface Props { groupValue: string; } -const paginationDecoder = decodeOrThrow(entityPaginationRt); - export function GroupedEntitiesGrid({ groupValue }: Props) { const { query } = useInventoryParams('/'); - const { sortField, sortDirection, pagination: paginationQuery } = query; + const { sortField, sortDirection, kuery } = query; + const { pagination, entityTypes } = useInventoryDecodedQueryParams(); const inventoryRoute = useInventoryRouter(); - let pagination: EntityPagination | undefined = {}; - const { stringifiedEsQuery } = useUnifiedSearchContext(); - try { - pagination = paginationDecoder(paginationQuery); - } catch (error) { - inventoryRoute.push('/', { - path: {}, - query: { - ...query, - pagination: undefined, - }, - }); - window.location.reload(); - } const pageIndex = pagination?.[groupValue] ?? 0; - const { refreshSubject$, isControlPanelsInitiated } = useUnifiedSearchContext(); + const { refreshSubject$ } = useUnifiedSearchContext(); const { services: { inventoryAPIClient }, } = useKibana(); @@ -57,28 +43,19 @@ export function GroupedEntitiesGrid({ groupValue }: Props) { refresh, } = useInventoryAbortableAsync( ({ signal }) => { - if (isControlPanelsInitiated) { - return inventoryAPIClient.fetch('GET /internal/inventory/entities', { - params: { - query: { - sortDirection, - sortField, - esQuery: stringifiedEsQuery, - entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined, - }, + return inventoryAPIClient.fetch('GET /internal/inventory/entities', { + params: { + query: { + sortDirection, + sortField, + kuery, + entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined, }, - signal, - }); - } + }, + signal, + }); }, - [ - groupValue, - inventoryAPIClient, - sortDirection, - sortField, - isControlPanelsInitiated, - stringifiedEsQuery, - ] + [groupValue, inventoryAPIClient, sortDirection, sortField, kuery] ); useEffectOnce(() => { @@ -111,6 +88,16 @@ export function GroupedEntitiesGrid({ groupValue }: Props) { }); } + function handleEntityTypeFilter(entityType: string, checkOption: EntityTypeCheckOptions) { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + entityTypes: entityTypesRt.encode({ ...entityTypes, [entityType]: checkOption }), + }, + }); + } + return ( ); } 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/entity_group_accordion/index.tsx similarity index 85% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/index.tsx index 0b4e9a46d4288..fa365625474b0 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_group_accordion.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/index.tsx @@ -8,27 +8,22 @@ import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, useEuiTheme } from '@elast import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; +import { EntityCountBadge } from './entity_count_badge'; import { GroupedEntitiesGrid } from './grouped_entities_grid'; -import { InventoryPanelBadge } from './inventory_panel_badge'; const ENTITIES_COUNT_BADGE = i18n.translate( 'xpack.inventory.inventoryGroupPanel.entitiesBadgeLabel', { defaultMessage: 'Entities' } ); -export interface InventoryGroupAccordionProps { +export interface Props { groupBy: string; groupValue: string; groupCount: number; isLoading?: boolean; } -export function InventoryGroupAccordion({ - groupBy, - groupValue, - groupCount, - isLoading, -}: InventoryGroupAccordionProps) { +export function EntityGroupAccordion({ groupBy, groupValue, groupCount, isLoading }: Props) { const { euiTheme } = useEuiTheme(); const [open, setOpen] = useState(false); @@ -55,8 +50,8 @@ export function InventoryGroupAccordion({ } buttonElement="div" extraAction={ - 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/entity_group_accordion/mock/inventory_component_wrapper_mock.tsx similarity index 100% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/mock/inventory_component_wrapper_mock.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/entity_group_accordion/mock/inventory_component_wrapper_mock.tsx diff --git a/x-pack/plugins/observability_solution/inventory/public/components/group_by_selector/group_by_selector.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/group_by_selector/group_by_selector.test.tsx new file mode 100644 index 0000000000000..6f504715e99c8 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/group_by_selector/group_by_selector.test.tsx @@ -0,0 +1,24 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; +import { GroupBySelector } from '.'; +import { InventoryComponentWrapperMock } from '../entity_group_accordion/mock/inventory_component_wrapper_mock'; + +describe('GroupBySelector', () => { + beforeEach(() => { + render( + + + + ); + }); + it('Should default to Type', async () => { + expect(await screen.findByText('Group entities by: Type')).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/group_by_selector/index.tsx similarity index 50% rename from x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.tsx rename to x-pack/plugins/observability_solution/inventory/public/components/group_by_selector/index.tsx index 95264f3c81303..b9aa28d3f2980 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/group_by_selector/index.tsx @@ -5,49 +5,17 @@ * 2.0. */ -import { EuiPopover, EuiContextMenu, EuiButtonEmpty } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui'; 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'; +import React, { useCallback, useState } from 'react'; -const GROUP_LABELS: Record = { - unified: i18n.translate('xpack.inventory.groupedInventoryPage.noneLabel', { - defaultMessage: 'None', - }), - grouped: i18n.translate('xpack.inventory.groupedInventoryPage.typeLabel', { - defaultMessage: 'Type', - }), -}; +const ENTITY_TYPE_LABEL = 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(); +export function GroupBySelector() { 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 = [ { @@ -56,17 +24,10 @@ export function GroupSelector() { 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'), + name: ENTITY_TYPE_LABEL, + icon: 'check', }, ], }, @@ -83,13 +44,13 @@ export function GroupSelector() { iconSize="s" iconType="arrowDown" onClick={onButtonClick} - title={GROUP_LABELS[groupBy]} + title={ENTITY_TYPE_LABEL} size="s" > ); 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 deleted file mode 100644 index 23cbb5b43c43b..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/group_selector.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx deleted file mode 100644 index 6cfdc079be299..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 { flattenObject } from '@kbn/observability-utils-common/object/flatten_object'; -import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; -import { useKibana } from '../../hooks/use_kibana'; -import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; -import { InventoryGroupAccordion } from './inventory_group_accordion'; -import { InventorySummary } from './inventory_summary'; - -export function GroupedInventory() { - const { - services: { inventoryAPIClient }, - } = useKibana(); - const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } = - useUnifiedSearchContext(); - - const { - value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 }, - refresh, - loading, - } = useInventoryAbortableAsync( - ({ signal }) => { - if (isControlPanelsInitiated) { - return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', { - params: { - path: { - field: ENTITY_TYPE, - }, - query: { esQuery: stringifiedEsQuery }, - }, - signal, - }); - } - }, - [inventoryAPIClient, stringifiedEsQuery, isControlPanelsInitiated] - ); - - useEffectOnce(() => { - const refreshSubscription = refreshSubject$.subscribe(refresh); - - return () => refreshSubscription.unsubscribe(); - }); - - return ( - <> - - - {value.groups.map((group) => { - const groupValue = flattenObject(group)[value.groupBy]; - return ( - - ); - })} - - ); -} 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 deleted file mode 100644 index 55697790c4ee9..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/components/grouped_inventory/inventory_summary.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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/search_bar/control_groups.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/control_groups.tsx deleted file mode 100644 index 9c263e39562f1..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/control_groups.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 { - ControlGroupRenderer, - ControlGroupRendererApi, - ControlGroupRuntimeState, -} from '@kbn/controls-plugin/public'; -import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; -import { ControlPanels, useControlPanels } from '@kbn/observability-shared-plugin/public'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { skip, Subscription } from 'rxjs'; -import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; - -const controlPanelDefinitions: ControlPanels = { - [ENTITY_TYPE]: { - order: 0, - type: 'optionsListControl', - fieldName: ENTITY_TYPE, - width: 'small', - grow: false, - title: 'Type', - }, -}; - -export function ControlGroups() { - const { - isControlPanelsInitiated, - setIsControlPanelsInitiated, - dataView, - searchState, - onPanelFiltersChange, - } = useUnifiedSearchContext(); - const [controlPanels, setControlPanels] = useControlPanels(controlPanelDefinitions, dataView); - const subscriptions = useRef(new Subscription()); - - const getInitialInput = useCallback( - () => async () => { - const initialInput: Partial = { - chainingSystem: 'HIERARCHICAL', - labelPosition: 'oneLine', - initialChildControlState: controlPanels, - }; - - return { initialState: initialInput }; - }, - [controlPanels] - ); - - const loadCompleteHandler = useCallback( - (controlGroup: ControlGroupRendererApi) => { - if (!controlGroup) return; - - subscriptions.current.add( - controlGroup.filters$.pipe(skip(1)).subscribe((newFilters = []) => { - onPanelFiltersChange(newFilters); - }) - ); - - subscriptions.current.add( - controlGroup.getInput$().subscribe(({ initialChildControlState }) => { - if (!isControlPanelsInitiated) { - setIsControlPanelsInitiated(true); - } - setControlPanels(initialChildControlState); - }) - ); - }, - [isControlPanelsInitiated, onPanelFiltersChange, setControlPanels, setIsControlPanelsInitiated] - ); - - useEffect(() => { - const currentSubscriptions = subscriptions.current; - return () => { - currentSubscriptions.unsubscribe(); - }; - }, []); - - if (!dataView) { - return null; - } - - return ( -
- -
- ); -} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_multi_select.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_multi_select.tsx new file mode 100644 index 0000000000000..0b4853e82a474 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/entity_types_multi_select.tsx @@ -0,0 +1,146 @@ +/* + * 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 { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableOption, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo, useState } from 'react'; +import { entityTypesRt, type EntityType } from '../../../common/rt_types'; +import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; +import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useInventoryRouter } from '../../hooks/use_inventory_router'; +import { useKibana } from '../../hooks/use_kibana'; +import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status'; + +export function EntityTypesMultiSelect() { + const inventoryRoute = useInventoryRouter(); + const { query } = useInventoryParams('/*'); + const { entityTypes: selectedEntityTypes } = useInventoryDecodedQueryParams(); + + const { + services: { inventoryAPIClient, telemetry }, + } = useKibana(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const { value, loading } = useInventoryAbortableAsync( + ({ signal }) => inventoryAPIClient.fetch('GET /internal/inventory/entities/types', { signal }), + [inventoryAPIClient] + ); + + const items = useMemo( + () => + value?.entityTypes.map((type): EuiSelectableOption => { + const checked = selectedEntityTypes?.[type]; + return { + label: type, + checked, + 'data-test-subj': `entityTypes_multiSelect_filter_selection_${type}`, + }; + }) || [], + [selectedEntityTypes, value?.entityTypes] + ); + + const registerEntityTypeFilteredEvent = useCallback( + ({ filterEntityTypes }: { filterEntityTypes: EntityType }) => { + const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(filterEntityTypes); + + telemetry.reportEntityInventoryEntityTypeFiltered({ + include_entity_types: entityTypesOn, + exclude_entity_types: entityTypesOff, + }); + }, + [telemetry] + ); + + function handleEntityTypeChecked(nextItems: EntityType) { + registerEntityTypeFilteredEvent({ filterEntityTypes: nextItems }); + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + entityTypes: entityTypesRt.encode(nextItems), + }, + }); + } + + return ( + + setIsPopoverOpen((state) => !state)} + isSelected={isPopoverOpen} + numFilters={items.filter((item) => item.checked !== 'off').length} + hasActiveFilters={!!items.find((item) => item.checked === 'on')} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + > + {i18n.translate('xpack.inventory.entityTypesMultSelect.typeFilterButtonLabel', { + defaultMessage: 'Type', + })} + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="none" + > + { + handleEntityTypeChecked( + newOptions + .filter((item) => item.checked) + .reduce((acc, curr) => ({ ...acc, [curr.label]: curr.checked! }), {}) + ); + }} + isLoading={loading} + loadingMessage={i18n.translate( + 'xpack.inventory.entityTypesMultSelect.euiSelectable.loading', + { defaultMessage: 'Loading types' } + )} + emptyMessage={i18n.translate( + 'xpack.inventory.entityTypesMultSelect.euiSelectable.empty', + { defaultMessage: 'No types available' } + )} + noMatchesMessage={i18n.translate( + 'xpack.inventory.entityTypesMultSelect.euiSelectable.notFound', + { defaultMessage: 'No types found' } + )} + > + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+
+
+ ); +} 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 3464c5749dbc3..16df0927df355 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 @@ -4,19 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Query } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import type { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar'; import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect } from 'react'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useInventoryRouter } from '../../hooks/use_inventory_router'; import { useKibana } from '../../hooks/use_kibana'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback'; -import { ControlGroups } from './control_groups'; +import { EntityTypesMultiSelect } from './entity_types_multi_select'; export function SearchBar() { - const { refreshSubject$, dataView, searchState, onQueryChange } = useUnifiedSearchContext(); - + const { refreshSubject$, dataView } = useUnifiedSearchContext(); + const inventoryRoute = useInventoryRouter(); + const { + query, + query: { kuery }, + } = useInventoryParams('/*'); const { services: { unifiedSearch, @@ -30,44 +35,40 @@ export function SearchBar() { const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui; const syncSearchBarWithUrl = useCallback(() => { - const query = searchState.query; - if (query && !deepEqual(queryStringService.getQuery(), query)) { - queryStringService.setQuery(query); + const _query = kuery ? { query: kuery, language: 'kuery' } : undefined; + if (_query && !deepEqual(queryStringService.getQuery(), _query)) { + queryStringService.setQuery(_query); } - if (!query) { + if (!_query) { queryStringService.clearQuery(); } - }, [searchState.query, queryStringService]); + }, [kuery, queryStringService]); useEffect(() => { syncSearchBarWithUrl(); }, [syncSearchBarWithUrl]); - const registerSearchSubmittedEvent = useCallback( - ({ searchQuery, searchIsUpdate }: { searchQuery?: Query; searchIsUpdate?: boolean }) => { - telemetry.reportEntityInventorySearchQuerySubmitted({ - kuery_fields: getKqlFieldsWithFallback(searchQuery?.query as string), - action: searchIsUpdate ? 'submit' : 'refresh', + const handleQuerySubmit = useCallback>( + ({ query: _query = { language: 'kuery', query: '' } }, isUpdate) => { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + kuery: _query?.query as string, + }, }); - }, - [telemetry] - ); - const handleQuerySubmit = useCallback>( - ({ query = { language: 'kuery', query: '' } }, isUpdate) => { - if (isUpdate) { - onQueryChange(query); - } else { + if (!isUpdate) { refreshSubject$.next(); } - registerSearchSubmittedEvent({ - searchQuery: query, - searchIsUpdate: isUpdate, + telemetry.reportEntityInventorySearchQuerySubmitted({ + kuery_fields: getKqlFieldsWithFallback(_query?.query as string), + action: isUpdate ? 'submit' : 'refresh', }); }, - [registerSearchSubmittedEvent, onQueryChange, refreshSubject$] + [inventoryRoute, query, telemetry, refreshSubject$] ); return ( @@ -75,16 +76,14 @@ export function SearchBar() { appName="Inventory" displayStyle="inPage" indexPatterns={dataView ? [dataView] : undefined} - renderQueryInputAppend={() => } + renderQueryInputAppend={() => } onQuerySubmit={handleQuerySubmit} placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { defaultMessage: 'Search for your entities by name or its metadata (e.g. entity.type : service)', })} showDatePicker={false} - showFilterBar - showQueryInput - showQueryMenu + showFilterBar={false} /> ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/unified_inventory/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/unified_inventory/index.tsx deleted file mode 100644 index 1bec6dee990d1..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/components/unified_inventory/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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 { decodeOrThrow } from '@kbn/io-ts-utils'; -import React from 'react'; -import useEffectOnce from 'react-use/lib/useEffectOnce'; -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'; -import { useKibana } from '../../hooks/use_kibana'; -import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; -import { EntitiesGrid } from '../entities_grid'; -import { InventorySummary } from '../grouped_inventory/inventory_summary'; - -const paginationDecoder = decodeOrThrow(entityPaginationRt); - -export function UnifiedInventory() { - const { - services: { inventoryAPIClient }, - } = useKibana(); - const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } = - useUnifiedSearchContext(); - const { query } = useInventoryParams('/'); - const { sortDirection, sortField, pagination: paginationQuery } = query; - - let pagination: EntityPagination | undefined = {}; - const inventoryRoute = useInventoryRouter(); - try { - pagination = paginationDecoder(paginationQuery); - } catch (error) { - inventoryRoute.push('/', { - path: {}, - query: { - ...query, - pagination: undefined, - }, - }); - window.location.reload(); - } - - const pageIndex = pagination?.unified ?? 0; - - const { - value = { entities: [] }, - loading, - refresh, - } = useInventoryAbortableAsync( - ({ signal }) => { - if (isControlPanelsInitiated) { - return inventoryAPIClient.fetch('GET /internal/inventory/entities', { - params: { - query: { - sortDirection, - sortField, - esQuery: stringifiedEsQuery, - }, - }, - signal, - }); - } - }, - [inventoryAPIClient, sortDirection, sortField, isControlPanelsInitiated, stringifiedEsQuery] - ); - - 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, - }, - }); - } - - return ( - <> - - - - ); -} diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts index 406dd44b505f4..dc9f5bf4a4740 100644 --- a/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts @@ -6,9 +6,9 @@ */ import { useCallback, useMemo } from 'react'; import type { InventoryEntity } from '../../common/entities'; -import { useKibana } from './use_kibana'; -import { useFetchEntityDefinition } from './use_fetch_entity_definition'; import { useAdHocDataView } from './use_adhoc_data_view'; +import { useFetchEntityDefinition } from './use_fetch_entity_definition'; +import { useKibana } from './use_kibana'; export const useDiscoverRedirect = (entity: InventoryEntity) => { const { diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_inventory_decoded_query_params.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_inventory_decoded_query_params.ts new file mode 100644 index 0000000000000..c02a33a8c06f7 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_inventory_decoded_query_params.ts @@ -0,0 +1,60 @@ +/* + * 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 { decodeOrThrow } from '@kbn/io-ts-utils'; +import { useCallback, useMemo } from 'react'; +import { + entityPaginationRt, + entityTypesRt, + type EntityPagination, + type EntityType, +} from '../../common/rt_types'; +import { useInventoryParams } from './use_inventory_params'; +import { useInventoryRouter } from './use_inventory_router'; + +const entityTypeDecoder = decodeOrThrow(entityTypesRt); +const paginationDecoder = decodeOrThrow(entityPaginationRt); + +export function useInventoryDecodedQueryParams() { + const inventoryRoute = useInventoryRouter(); + const { + query, + query: { entityTypes, pagination }, + } = useInventoryParams('/*'); + + const resetUrlParam = useCallback( + (queryParamName: string) => { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + [queryParamName]: undefined, + }, + }); + }, + [inventoryRoute, query] + ); + + const selectedEntityTypes: EntityType = useMemo(() => { + try { + return entityTypeDecoder(entityTypes) || {}; + } catch (e) { + resetUrlParam('entityTypes'); + return {}; + } + }, [entityTypes, resetUrlParam]); + + const selectedPagination: EntityPagination = useMemo(() => { + try { + return paginationDecoder(pagination) || {}; + } catch (error) { + resetUrlParam('pagination'); + return {}; + } + }, [pagination, resetUrlParam]); + + return { entityTypes: selectedEntityTypes, pagination: selectedPagination }; +} diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_context.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_context.ts index 3c520867540c9..c5715fb1d50b6 100644 --- a/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_context.ts +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_context.ts @@ -4,162 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { buildEsQuery, type Filter, fromKueryExpression, type Query } from '@kbn/es-query'; import createContainer from 'constate'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { map, Subject, Subscription, tap } from 'rxjs'; -import { generateFilters } from '@kbn/data-plugin/public'; -import useEffectOnce from 'react-use/lib/useEffectOnce'; -import deepEqual from 'fast-deep-equal'; -import { i18n } from '@kbn/i18n'; -import { useKibanaQuerySettings } from '@kbn/observability-shared-plugin/public'; +import { useState } from 'react'; +import { Subject } from 'rxjs'; import { ENTITIES_LATEST_ALIAS } from '../../common/entities'; import { useAdHocDataView } from './use_adhoc_data_view'; -import { useUnifiedSearchUrl } from './use_unified_search_url'; -import { useKibana } from './use_kibana'; function useUnifiedSearch() { - const [isControlPanelsInitiated, setIsControlPanelsInitiated] = useState(false); const { dataView } = useAdHocDataView(ENTITIES_LATEST_ALIAS); const [refreshSubject$] = useState>(new Subject()); - const { searchState, setSearchState } = useUnifiedSearchUrl(); - const kibanaQuerySettings = useKibanaQuerySettings(); - const { - services: { - data: { - query: { filterManager: filterManagerService, queryString: queryStringService }, - }, - notifications, - }, - } = useKibana(); - - useEffectOnce(() => { - if (!deepEqual(filterManagerService.getFilters(), searchState.filters)) { - filterManagerService.setFilters( - searchState.filters.map((item) => ({ - ...item, - meta: { ...item.meta, index: dataView?.id }, - })) - ); - } - - if (!deepEqual(queryStringService.getQuery(), searchState.query)) { - queryStringService.setQuery(searchState.query); - } - }); - - useEffect(() => { - const subscription = new Subscription(); - subscription.add( - filterManagerService - .getUpdates$() - .pipe( - map(() => filterManagerService.getFilters()), - tap((filters) => setSearchState({ type: 'SET_FILTERS', filters })) - ) - .subscribe() - ); - - subscription.add( - queryStringService - .getUpdates$() - .pipe( - map(() => queryStringService.getQuery() as Query), - tap((query) => setSearchState({ type: 'SET_QUERY', query })) - ) - .subscribe() - ); - - return () => { - subscription.unsubscribe(); - }; - }, [filterManagerService, queryStringService, setSearchState]); - - const validateQuery = useCallback( - (query: Query) => { - fromKueryExpression(query.query, kibanaQuerySettings); - }, - [kibanaQuerySettings] - ); - - const onQueryChange = useCallback( - (query: Query) => { - try { - validateQuery(query); - setSearchState({ type: 'SET_QUERY', query }); - } catch (e) { - const err = e as Error; - notifications.toasts.addDanger({ - title: i18n.translate('xpack.inventory.unifiedSearchContext.queryError', { - defaultMessage: 'Error while updating the new query', - }), - text: err.message, - }); - } - }, - [validateQuery, setSearchState, notifications.toasts] - ); - - const onPanelFiltersChange = useCallback( - (panelFilters: Filter[]) => { - setSearchState({ type: 'SET_PANEL_FILTERS', panelFilters }); - }, - [setSearchState] - ); - - const onFiltersChange = useCallback( - (filters: Filter[]) => { - setSearchState({ type: 'SET_FILTERS', filters }); - }, - [setSearchState] - ); - - const addFilter = useCallback( - ({ - fieldName, - operation, - value, - }: { - fieldName: string; - value: string; - operation: '+' | '-'; - }) => { - if (dataView) { - const newFilters = generateFilters( - filterManagerService, - fieldName, - value, - operation, - dataView - ); - setSearchState({ type: 'SET_FILTERS', filters: [...newFilters, ...searchState.filters] }); - } - }, - [dataView, filterManagerService, searchState.filters, setSearchState] - ); - - const stringifiedEsQuery = useMemo(() => { - if (dataView) { - return JSON.stringify( - buildEsQuery(dataView, searchState.query, [ - ...searchState.panelFilters, - ...searchState.filters, - ]) - ); - } - }, [dataView, searchState.panelFilters, searchState.filters, searchState.query]); return { - isControlPanelsInitiated, - setIsControlPanelsInitiated, dataView, refreshSubject$, - searchState, - addFilter, - stringifiedEsQuery, - onQueryChange, - onPanelFiltersChange, - onFiltersChange, }; } diff --git a/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_url.ts b/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_url.ts deleted file mode 100644 index 17cf0ef0d9597..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/public/hooks/use_unified_search_url.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { FilterStateStore } from '@kbn/es-query'; -import { useUrlState } from '@kbn/observability-shared-plugin/public'; -import { enumeration } from '@kbn/securitysolution-io-ts-types'; -import { fold } from 'fp-ts/lib/Either'; -import { constant, identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; -import * as t from 'io-ts'; -import { useReducer } from 'react'; -import deepEqual from 'fast-deep-equal'; - -const FilterRT = t.intersection([ - t.type({ - meta: t.partial({ - alias: t.union([t.null, t.string]), - disabled: t.boolean, - negate: t.boolean, - controlledBy: t.string, - group: t.string, - index: t.string, - isMultiIndex: t.boolean, - type: t.string, - key: t.string, - params: t.any, - value: t.any, - }), - }), - t.partial({ - query: t.record(t.string, t.any), - $state: t.type({ - store: enumeration('FilterStateStore', FilterStateStore), - }), - }), -]); -const FiltersRT = t.array(FilterRT); - -const QueryStateRT = t.type({ - language: t.string, - query: t.union([t.string, t.record(t.string, t.any)]), -}); - -const SearchStateRT = t.type({ - panelFilters: FiltersRT, - filters: FiltersRT, - query: QueryStateRT, -}); - -const encodeUrlState = SearchStateRT.encode; -const decodeUrlState = (value: unknown) => { - return pipe(SearchStateRT.decode(value), fold(constant(undefined), identity)); -}; - -type SearchState = t.TypeOf; - -const INITIAL_VALUE: SearchState = { - query: { language: 'kuery', query: '' }, - panelFilters: [], - filters: [], -}; - -export type StateAction = - | { type: 'SET_FILTERS'; filters: SearchState['filters'] } - | { type: 'SET_QUERY'; query: SearchState['query'] } - | { type: 'SET_PANEL_FILTERS'; panelFilters: SearchState['panelFilters'] }; - -const reducer = (state: SearchState, action: StateAction): SearchState => { - switch (action.type) { - case 'SET_FILTERS': - return { ...state, filters: action.filters }; - case 'SET_QUERY': - return { ...state, query: action.query }; - case 'SET_PANEL_FILTERS': - return { ...state, panelFilters: action.panelFilters }; - default: - return state; - } -}; - -export function useUnifiedSearchUrl() { - const [urlState, setUrlState] = useUrlState({ - defaultState: INITIAL_VALUE, - decodeUrlState, - encodeUrlState, - urlStateKey: '_a', - writeDefaultState: true, - }); - - const [searchState, setSearchState] = useReducer(reducer, urlState); - - if (!deepEqual(searchState, urlState)) { - setUrlState(searchState); - } - - return { searchState, setSearchState }; -} 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 f34df1a3c8b32..6eab905a40692 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,12 +4,83 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import { flattenObject } from '@kbn/observability-utils-common/object/flatten_object'; import React from 'react'; -import { GroupedInventory } from '../../components/grouped_inventory'; -import { UnifiedInventory } from '../../components/unified_inventory'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { EntitiesSummary } from '../../components/entities_summary'; +import { EntityGroupAccordion } from '../../components/entity_group_accordion'; +import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; +import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params'; import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useKibana } from '../../hooks/use_kibana'; +import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context'; +import { GroupBySelector } from '../../components/group_by_selector'; +import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status'; export function InventoryPage() { - const { query } = useInventoryParams('/'); - return query.view === 'unified' ? : ; + const { + services: { inventoryAPIClient }, + } = useKibana(); + const { refreshSubject$ } = useUnifiedSearchContext(); + const { + query: { kuery }, + } = useInventoryParams('/'); + const { entityTypes } = useInventoryDecodedQueryParams(); + + const { + value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 }, + refresh, + loading, + } = useInventoryAbortableAsync( + ({ signal }) => { + const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(entityTypes); + return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', { + params: { + path: { + field: ENTITY_TYPE, + }, + query: { + includeEntityTypes: entityTypesOn.length ? JSON.stringify(entityTypesOn) : undefined, + excludeEntityTypes: entityTypesOff.length ? JSON.stringify(entityTypesOff) : undefined, + kuery, + }, + }, + signal, + }); + }, + [entityTypes, inventoryAPIClient, kuery] + ); + + useEffectOnce(() => { + const refreshSubscription = refreshSubject$.subscribe(refresh); + return () => refreshSubscription.unsubscribe(); + }); + + return ( + <> + + + + + + + + + + {value.groups.map((group) => { + const groupValue = flattenObject(group)[value.groupBy]; + return ( + + ); + })} + + ); } 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 bf5f8324aab25..32db48130a89c 100644 --- a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx @@ -7,9 +7,9 @@ import { Outlet, createRouter } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; import React from 'react'; +import { defaultEntitySortField, entityColumnIdsRt } from '../../common/entities'; import { InventoryPageTemplate } from '../components/inventory_page_template'; import { InventoryPage } from '../pages/inventory_page'; -import { defaultEntitySortField, entityColumnIdsRt, entityViewRt } from '../../common/entities'; /** * The array of route definitions to be used when the application @@ -29,10 +29,9 @@ const inventoryRoutes = { sortDirection: t.union([t.literal('asc'), t.literal('desc')]), }), t.partial({ - view: entityViewRt, pagination: t.string, - _a: t.string, - controlPanels: t.string, + entityTypes: t.string, + kuery: t.string, }), ]), }), @@ -40,7 +39,6 @@ const inventoryRoutes = { query: { sortField: defaultEntitySortField, sortDirection: 'desc', - view: 'grouped', }, }, children: { diff --git a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_client.ts index c4c238fba5f8f..d7806c2f6cb2e 100644 --- a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_client.ts +++ b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_client.ts @@ -14,6 +14,7 @@ import { type EntityInventoryViewedParams, type EntityInventorySearchQuerySubmittedParams, type EntityViewClickedParams, + type EntityInventoryEntityTypeFilteredParams, } from './types'; export class TelemetryClient implements ITelemetryClient { @@ -36,4 +37,10 @@ export class TelemetryClient implements ITelemetryClient { public reportEntityViewClicked = (params: EntityViewClickedParams) => { this.analytics.reportEvent(TelemetryEventTypes.ENTITY_VIEW_CLICKED, params); }; + + public reportEntityInventoryEntityTypeFiltered = ( + params: EntityInventoryEntityTypeFilteredParams + ) => { + this.analytics.reportEvent(TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, params); + }; } diff --git a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_events.ts index ec2623fe2a2cc..707852f9f3cd6 100644 --- a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_events.ts +++ b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_events.ts @@ -58,6 +58,30 @@ const searchQuerySubmittedEventType: TelemetryEvent = { }, }; +const entityInventoryEntityTypeFilteredEventType: TelemetryEvent = { + eventType: TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, + schema: { + include_entity_types: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'List of Entity types used to filter for.', + }, + }, + }, + exclude_entity_types: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'List of Entity types used to filter out.', + }, + }, + }, + }, +}; + const entityViewClickedEventType: TelemetryEvent = { eventType: TelemetryEventTypes.ENTITY_VIEW_CLICKED, schema: { @@ -81,4 +105,5 @@ export const inventoryTelemetryEventBasedTypes = [ entityInventoryViewedEventType, searchQuerySubmittedEventType, entityViewClickedEventType, + entityInventoryEntityTypeFilteredEventType, ]; diff --git a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_service.test.ts index 639b771788f5b..6a4854f754831 100644 --- a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_service.test.ts +++ b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/telemetry_service.test.ts @@ -13,6 +13,7 @@ import { type EntityViewClickedParams, type EntityInventorySearchQuerySubmittedParams, TelemetryEventTypes, + EntityInventoryEntityTypeFilteredParams, } from './types'; describe('TelemetryService', () => { @@ -145,4 +146,24 @@ describe('TelemetryService', () => { ); }); }); + + describe('#reportEntityInventoryEntityTypeFiltered', () => { + it('should report entity type filtered with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + const params: EntityInventoryEntityTypeFilteredParams = { + include_entity_types: ['container'], + exclude_entity_types: ['service'], + }; + + telemetry.reportEntityInventoryEntityTypeFiltered(params); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, + params + ); + }); + }); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/types.ts b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/types.ts index 0d56f44c2c2f2..e8ce3eb94e9ba 100644 --- a/x-pack/plugins/observability_solution/inventory/public/services/telemetry/types.ts +++ b/x-pack/plugins/observability_solution/inventory/public/services/telemetry/types.ts @@ -30,6 +30,11 @@ export interface EntityInventorySearchQuerySubmittedParams { action: 'submit' | 'refresh'; } +export interface EntityInventoryEntityTypeFilteredParams { + include_entity_types: string[]; + exclude_entity_types: string[]; +} + export interface EntityViewClickedParams { entity_type: string; view_type: 'detail' | 'flyout'; @@ -39,7 +44,8 @@ export type TelemetryEventParams = | InventoryAddDataParams | EntityInventoryViewedParams | EntityInventorySearchQuerySubmittedParams - | EntityViewClickedParams; + | EntityViewClickedParams + | EntityInventoryEntityTypeFilteredParams; export interface ITelemetryClient { reportInventoryAddData(params: InventoryAddDataParams): void; @@ -48,6 +54,7 @@ export interface ITelemetryClient { params: EntityInventorySearchQuerySubmittedParams ): void; reportEntityViewClicked(params: EntityViewClickedParams): void; + reportEntityInventoryEntityTypeFiltered(params: EntityInventoryEntityTypeFilteredParams): void; } export enum TelemetryEventTypes { diff --git a/x-pack/plugins/observability_solution/inventory/public/utils/group_entity_types_by_status.ts b/x-pack/plugins/observability_solution/inventory/public/utils/group_entity_types_by_status.ts new file mode 100644 index 0000000000000..f842663dd56e9 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/utils/group_entity_types_by_status.ts @@ -0,0 +1,16 @@ +/* + * 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 { EntityType } from '../../common/rt_types'; + +export function groupEntityTypesByStatus(entityTypes: EntityType) { + const entityTypesKeys = Object.keys(entityTypes); + return { + entityTypesOn: entityTypesKeys.filter((key) => entityTypes[key] === 'on').sort(), + entityTypesOff: entityTypesKeys.filter((key) => entityTypes[key] === 'off').sort(), + }; +} 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 index 816b3c6af6ec2..ead3109060d13 100644 --- 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 @@ -5,26 +5,43 @@ * 2.0. */ -import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types'; +import { kqlQuery } from '@kbn/observability-plugin/server'; +import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; import { ENTITIES_LATEST_ALIAS, - type EntityGroup, MAX_NUMBER_OF_ENTITIES, + type EntityGroup, } from '../../../common/entities'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper'; export async function getEntityGroupsBy({ inventoryEsClient, field, - esQuery, + kuery, + includeEntityTypes = [], + excludeEntityTypes = [], }: { inventoryEsClient: ObservabilityElasticsearchClient; field: string; - esQuery?: QueryDslQueryContainer; + includeEntityTypes?: string[]; + excludeEntityTypes?: string[]; + kuery?: string; }): Promise { const from = `FROM ${ENTITIES_LATEST_ALIAS}`; const where = [getBuiltinEntityDefinitionIdESQLWhereClause()]; + const params: ScalarValue[] = []; + + if (includeEntityTypes.length) { + where.push(`WHERE ${ENTITY_TYPE} IN (${includeEntityTypes.map(() => '?').join()})`); + params.push(...includeEntityTypes); + } + + if (excludeEntityTypes.length) { + where.push(`WHERE ${ENTITY_TYPE} NOT IN (${excludeEntityTypes.map(() => '?').join()})`); + params.push(...excludeEntityTypes); + } const group = `STATS count = COUNT(*) by ${field}`; const sort = `SORT ${field} asc`; @@ -35,7 +52,8 @@ export async function getEntityGroupsBy({ 'get_entities_groups', { query, - filter: esQuery, + filter: { bool: { filter: kqlQuery(kuery) } }, + params, }, { transform: 'plain' } ); 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 9dcf17250ad68..83f576220d12a 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 @@ -5,19 +5,20 @@ * 2.0. */ -import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types'; +import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types'; +import { kqlQuery } from '@kbn/observability-plugin/server'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, ENTITY_TYPE, } from '@kbn/observability-shared-plugin/common'; -import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; import { unflattenObject } from '@kbn/observability-utils-common/object/unflatten_object'; +import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; import { ENTITIES_LATEST_ALIAS, - InventoryEntity, MAX_NUMBER_OF_ENTITIES, type EntityColumnIds, + type InventoryEntity, } from '../../../common/entities'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper'; @@ -35,13 +36,13 @@ export async function getLatestEntities({ inventoryEsClient, sortDirection, sortField, - esQuery, + kuery, entityTypes, }: { inventoryEsClient: ObservabilityElasticsearchClient; sortDirection: 'asc' | 'desc'; sortField: EntityColumnIds; - esQuery?: QueryDslQueryContainer; + kuery?: string; entityTypes?: string[]; }): Promise { // alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default. @@ -78,7 +79,7 @@ export async function getLatestEntities({ 'get_latest_entities', { query, - filter: esQuery, + filter: { bool: { filter: kqlQuery(kuery) } }, params, }, { transform: 'plain' } 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 e10053107caa8..c4a0a21f50eb2 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 @@ -47,7 +47,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ sortDirection: t.union([t.literal('asc'), t.literal('desc')]), }), t.partial({ - esQuery: jsonRt.pipe(t.UnknownRecord), + kuery: t.string, entityTypes: jsonRt.pipe(t.array(t.string)), }), ]), @@ -69,7 +69,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ plugin: `@kbn/${INVENTORY_APP_ID}-plugin`, }); - const { sortDirection, sortField, esQuery, entityTypes } = params.query; + const { sortDirection, sortField, kuery, entityTypes } = params.query; const [alertsClient, latestEntities] = await Promise.all([ createAlertsClient({ plugins, request }), @@ -77,7 +77,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ inventoryEsClient, sortDirection, sortField, - esQuery, + kuery, entityTypes, }), ]); @@ -113,7 +113,9 @@ export const groupEntitiesByRoute = createInventoryServerRoute({ t.type({ path: t.type({ field: t.literal(ENTITY_TYPE) }) }), t.partial({ query: t.partial({ - esQuery: jsonRt.pipe(t.UnknownRecord), + includeEntityTypes: jsonRt.pipe(t.array(t.string)), + excludeEntityTypes: jsonRt.pipe(t.array(t.string)), + kuery: t.string, }), }), ]), @@ -129,12 +131,14 @@ export const groupEntitiesByRoute = createInventoryServerRoute({ }); const { field } = params.path; - const { esQuery } = params.query ?? {}; + const { kuery, includeEntityTypes, excludeEntityTypes } = params.query ?? {}; const groups = await getEntityGroupsBy({ inventoryEsClient, field, - esQuery, + kuery, + includeEntityTypes, + excludeEntityTypes, }); const entitiesCount = groups.reduce((acc, group) => acc + group.count, 0); diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index b276c3e7e28b1..561ca62eaf97e 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -54,8 +54,6 @@ "@kbn/storybook", "@kbn/dashboard-plugin", "@kbn/deeplinks-analytics", - "@kbn/controls-plugin", - "@kbn/securitysolution-io-ts-types", "@kbn/react-hooks", "@kbn/observability-utils-common", "@kbn/observability-utils-browser",