From fcc3b0654525d67d34cc5916b4b6f7351892f650 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Fri, 8 Nov 2024 19:18:07 +0100 Subject: [PATCH] [ObsUx][Inventory] Add actions column with link to discover for inventory (#199306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #199025 ## Summary This PR adds an actions column with a link to discover for inventory. It is available in the inventory grid(s) in both the group view and the unified inventory view. The column header tooltip text will change when it's available: [issue](https://github.com/elastic/kibana/issues/199500) added ⚠️ If the discover link is not available I added a logic to hide the whole actions column as this is the only available option for now. Once we add more actions we should refactor that to just not add the action and to keep the column visible (which doesn't make sense atm) ## Testing - Enable the Inventory - Check with/without grouping both the action link and the button - combination of kuery / drop-down filter - without any filters - With just one kuery or drop-down filter - When the link is clicked from the table we should see a filter by identity field in the query in discover (like `service.name: 'test'`, `conteainer.id: 'test'`) https://github.com/user-attachments/assets/bb4a89f5-2b30-457f-bf13-7580ff162a7e https://github.com/user-attachments/assets/2894ef5c-6622-4488-ab84-c453f5b6e318 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../inventory/common/entities.ts | 1 + .../inventory/e2e/cypress/e2e/home.cy.ts | 16 ++++ .../components/entities_grid/grid_columns.tsx | 23 ++++- .../public/components/entities_grid/index.tsx | 24 +++++- .../components/entity_actions/index.tsx | 62 ++++++++++++++ .../components/search_bar/discover_button.tsx | 50 ++--------- .../public/components/search_bar/index.tsx | 7 +- .../index.tsx | 10 ++- .../public/hooks/use_discover_redirect.ts | 85 +++++++++++++++++++ .../inventory/tsconfig.json | 3 +- 10 files changed, 223 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entity_actions/index.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index f686490b90bfc..507d9d492c0f7 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -23,6 +23,7 @@ export const entityColumnIdsRt = t.union([ t.literal(ENTITY_LAST_SEEN), t.literal(ENTITY_TYPE), t.literal('alertsCount'), + t.literal('actions'), ]); export type EntityColumnIds = t.TypeOf; 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 501b6b8078da5..d24953c38eb13 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 @@ -229,6 +229,22 @@ describe('Home page', () => { cy.getByTestSubj('inventoryGroup_entity.type_host').should('not.exist'); cy.getByTestSubj('inventoryGroup_entity.type_service').should('not.exist'); }); + + it('Navigates to discover with actions button in the entities list', () => { + cy.intercept('GET', '/internal/entities/managed/enablement', { + fixture: 'eem_enabled.json', + }).as('getEEMStatus'); + cy.visitKibana('/app/inventory'); + cy.wait('@getEEMStatus'); + cy.contains('container'); + cy.getByTestSubj('inventoryGroupTitle_entity.type_container').click(); + cy.getByTestSubj('inventoryEntityActionsButton-foo').click(); + cy.getByTestSubj('inventoryEntityActionOpenInDiscover').click(); + cy.url().should( + 'include', + "query:'container.id:%20foo%20AND%20entity.definition_id%20:%20builtin*" + ); + }); }); }); }); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx index 96fb8b3736ead..d514dc9199aec 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx @@ -45,13 +45,17 @@ const entityLastSeenLabel = i18n.translate( defaultMessage: 'Last seen', } ); -const entityLastSeenToolip = i18n.translate( +const entityLastSeenTooltip = i18n.translate( 'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip', { defaultMessage: 'Timestamp of last received data for entity (entity.lastSeenTimestamp)', } ); +const entityActionsLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.actionsLabel', { + defaultMessage: 'Actions', +}); + const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( <> {title} @@ -68,8 +72,10 @@ const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipCon export const getColumns = ({ showAlertsColumn, + showActions, }: { showAlertsColumn: boolean; + showActions: boolean; }): EuiDataGridColumn[] => { return [ ...(showAlertsColumn @@ -103,11 +109,24 @@ export const getColumns = ({ // keep it for accessibility purposes displayAsText: entityLastSeenLabel, display: ( - + ), defaultSortDirection: 'desc', isSortable: true, schema: 'datetime', }, + ...(showActions + ? [ + { + id: 'actions', + // keep it for accessibility purposes + displayAsText: entityActionsLabel, + display: ( + + ), + initialWidth: 100, + }, + ] + : []), ]; }; 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 e3c0d24837f91..7819e944c486d 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 @@ -26,6 +26,8 @@ import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; import { getColumns } from './grid_columns'; import { AlertsBadge } from '../alerts_badge/alerts_badge'; import { EntityName } from './entity_name'; +import { EntityActions } from '../entity_actions'; +import { useDiscoverRedirect } from '../../hooks/use_discover_redirect'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; type LatestEntities = InventoryEntitiesAPIReturnType['entities']; @@ -53,6 +55,8 @@ export function EntitiesGrid({ onChangeSort, onFilterByType, }: Props) { + const { getDiscoverRedirectUrl } = useDiscoverRedirect(); + const onSort: EuiDataGridSorting['onSort'] = useCallback( (newSortingColumns) => { const lastItem = last(newSortingColumns); @@ -68,12 +72,14 @@ export function EntitiesGrid({ [entities] ); + const showActions = useMemo(() => !!getDiscoverRedirectUrl(), [getDiscoverRedirectUrl]); + const columnVisibility = useMemo( () => ({ - visibleColumns: getColumns({ showAlertsColumn }).map(({ id }) => id), + visibleColumns: getColumns({ showAlertsColumn, showActions }).map(({ id }) => id), setVisibleColumns: () => {}, }), - [showAlertsColumn] + [showAlertsColumn, showActions] ); const renderCellValue = useCallback( @@ -85,6 +91,7 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; const entityType = entity[ENTITY_TYPE]; + const discoverUrl = getDiscoverRedirectUrl(entity); switch (columnEntityTableId) { case 'alertsCount': @@ -127,11 +134,20 @@ export function EntitiesGrid({ ); case ENTITY_DISPLAY_NAME: return ; + case 'actions': + return ( + discoverUrl && ( + + ) + ); default: return entity[columnId as EntityColumnIds] || ''; } }, - [entities, onFilterByType] + [entities, getDiscoverRedirectUrl, onFilterByType] ); if (loading) { @@ -146,7 +162,7 @@ export function EntitiesGrid({ 'xpack.inventory.entitiesGrid.euiDataGrid.inventoryEntitiesGridLabel', { defaultMessage: 'Inventory entities grid' } )} - columns={getColumns({ showAlertsColumn })} + columns={getColumns({ showAlertsColumn, showActions })} columnVisibility={columnVisibility} rowCount={entities.length} renderCellValue={renderCellValue} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entity_actions/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entity_actions/index.tsx new file mode 100644 index 0000000000000..95a4050fba4e9 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entity_actions/index.tsx @@ -0,0 +1,62 @@ +/* + * 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useBoolean } from '@kbn/react-hooks'; + +interface Props { + discoverUrl: string; + entityIdentifyingValue?: string; +} + +export const EntityActions = ({ discoverUrl, entityIdentifyingValue }: Props) => { + const [isPopoverOpen, { toggle: togglePopover, off: closePopover }] = useBoolean(false); + const actionButtonTestSubject = entityIdentifyingValue + ? `inventoryEntityActionsButton-${entityIdentifyingValue}` + : 'inventoryEntityActionsButton'; + + const actions = [ + + {i18n.translate('xpack.inventory.entityActions.discoverLink', { + defaultMessage: 'Open in discover', + })} + , + ]; + + return ( + <> + + } + closePopover={closePopover} + > + + + + ); +}; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx index d5ed5b5af8cf9..13477d63e5f82 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx @@ -7,56 +7,16 @@ import { EuiButton } from '@elastic/eui'; import { DataView } from '@kbn/data-views-plugin/public'; -import { buildPhrasesFilter, PhrasesFilter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; - -import { - ENTITY_DEFINITION_ID, - ENTITY_DISPLAY_NAME, - ENTITY_LAST_SEEN, - ENTITY_TYPE, -} from '@kbn/observability-shared-plugin/common'; -import { EntityColumnIds } from '../../../common/entities'; -import { useInventoryParams } from '../../hooks/use_inventory_params'; -import { useKibana } from '../../hooks/use_kibana'; - -const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; +import React from 'react'; +import { useDiscoverRedirect } from '../../hooks/use_discover_redirect'; export function DiscoverButton({ dataView }: { dataView: DataView }) { - const { - services: { share, application }, - } = useKibana(); - const { - query: { kuery, entityTypes }, - } = useInventoryParams('/*'); - - const discoverLocator = useMemo( - () => share.url.locators.get('DISCOVER_APP_LOCATOR'), - [share.url.locators] - ); - - const filters: PhrasesFilter[] = []; - - const entityTypeField = dataView.getFieldByName(ENTITY_TYPE); - - if (entityTypes && entityTypeField) { - const entityTypeFilter = buildPhrasesFilter(entityTypeField, entityTypes, dataView); - filters.push(entityTypeFilter); - } - - const kueryWithEntityDefinitionFilters = [kuery, `${ENTITY_DEFINITION_ID} : builtin*`] - .filter(Boolean) - .join(' AND '); + const { getDiscoverRedirectUrl } = useDiscoverRedirect(); - const discoverLink = discoverLocator?.getRedirectUrl({ - indexPatternId: dataView?.id ?? '', - columns: ACTIVE_COLUMNS, - query: { query: kueryWithEntityDefinitionFilters, language: 'kuery' }, - filters, - }); + const discoverLink = getDiscoverRedirectUrl(); - if (!application.capabilities.discover?.show || !discoverLink) { + if (!discoverLink) { return null; } 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 2fd450aab30dd..3a22d9bc19a1d 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 @@ -11,7 +11,6 @@ import React, { useCallback, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Query } from '@kbn/es-query'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; -import { useAdHocInventoryDataView } from '../../hooks/use_adhoc_inventory_data_view'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useKibana } from '../../hooks/use_kibana'; import { EntityTypesControls } from './entity_types_controls'; @@ -19,7 +18,7 @@ import { DiscoverButton } from './discover_button'; import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback'; export function SearchBar() { - const { searchBarContentSubject$, refreshSubject$ } = useInventorySearchBarContext(); + const { refreshSubject$, searchBarContentSubject$, dataView } = useInventorySearchBarContext(); const { services: { unifiedSearch, @@ -36,8 +35,6 @@ export function SearchBar() { const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui; - const { dataView } = useAdHocInventoryDataView(); - const syncSearchBarWithUrl = useCallback(() => { const query = kuery ? { query: kuery, language: 'kuery' } : undefined; if (query && !deepEqual(queryStringService.getQuery(), query)) { @@ -107,7 +104,7 @@ export function SearchBar() { refreshSubject$.next(); } }, - [entityTypes, registerSearchSubmittedEvent, searchBarContentSubject$, refreshSubject$] + [searchBarContentSubject$, entityTypes, registerSearchSubmittedEvent, 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 eb5a2a057e529..f5a71e80bd9a3 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 @@ -6,6 +6,8 @@ */ import React, { createContext, useContext, type ReactChild } from 'react'; import { Subject } from 'rxjs'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { useAdHocInventoryDataView } from '../../hooks/use_adhoc_inventory_data_view'; interface InventorySearchBarContextType { searchBarContentSubject$: Subject<{ @@ -13,6 +15,7 @@ interface InventorySearchBarContextType { entityTypes?: string[]; }>; refreshSubject$: Subject; + dataView?: DataView; } const InventorySearchBarContext = createContext({ @@ -21,9 +24,14 @@ const InventorySearchBarContext = createContext({ }); export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) { + const { dataView } = useAdHocInventoryDataView(); return ( {children} 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 new file mode 100644 index 0000000000000..3c6ba331ec2a0 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/hooks/use_discover_redirect.ts @@ -0,0 +1,85 @@ +/* + * 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 { + ENTITY_TYPE, + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_LAST_SEEN, +} from '@kbn/observability-shared-plugin/common'; +import { useCallback } from 'react'; +import { type PhrasesFilter, buildPhrasesFilter } from '@kbn/es-query'; +import type { DataViewField } from '@kbn/data-views-plugin/public'; +import type { Entity, EntityColumnIds } from '../../common/entities'; +import { unflattenEntity } from '../../common/utils/unflatten_entity'; +import { useKibana } from './use_kibana'; +import { useInventoryParams } from './use_inventory_params'; +import { useInventorySearchBarContext } from '../context/inventory_search_bar_context_provider'; + +const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; + +export const useDiscoverRedirect = () => { + const { + services: { share, application, entityManager }, + } = useKibana(); + const { + query: { kuery, entityTypes }, + } = useInventoryParams('/*'); + + const { dataView } = useInventorySearchBarContext(); + + const discoverLocator = share.url.locators.get('DISCOVER_APP_LOCATOR'); + + const getDiscoverEntitiesRedirectUrl = useCallback( + (entity?: Entity) => { + const filters: PhrasesFilter[] = []; + + const entityTypeField = (dataView?.getFieldByName(ENTITY_TYPE) ?? + entity?.[ENTITY_TYPE]) as DataViewField; + + if (entityTypes && entityTypeField && dataView) { + const entityTypeFilter = buildPhrasesFilter(entityTypeField, entityTypes, dataView); + filters.push(entityTypeFilter); + } + + const entityKqlFilter = entity + ? entityManager.entityClient.asKqlFilter(unflattenEntity(entity)) + : ''; + + const kueryWithEntityDefinitionFilters = [ + kuery, + entityKqlFilter, + `${ENTITY_DEFINITION_ID} : builtin*`, + ] + .filter(Boolean) + .join(' AND '); + + return application.capabilities.discover?.show + ? discoverLocator?.getRedirectUrl({ + indexPatternId: dataView?.id ?? '', + columns: ACTIVE_COLUMNS, + query: { query: kueryWithEntityDefinitionFilters, language: 'kuery' }, + filters, + }) + : undefined; + }, + [ + application.capabilities.discover?.show, + discoverLocator, + entityManager.entityClient, + entityTypes, + kuery, + dataView, + ] + ); + + const getDiscoverRedirectUrl = useCallback( + (entity?: Entity) => getDiscoverEntitiesRedirectUrl(entity), + [getDiscoverEntitiesRedirectUrl] + ); + + return { getDiscoverRedirectUrl }; +}; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index bd77df478cad1..d41ef612c9574 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -55,6 +55,7 @@ "@kbn/storybook", "@kbn/zod", "@kbn/dashboard-plugin", - "@kbn/deeplinks-analytics" + "@kbn/deeplinks-analytics", + "@kbn/react-hooks" ] }