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" ] }