Skip to content

Commit

Permalink
[ObsUx][Inventory] Add actions column with link to discover for inven…
Browse files Browse the repository at this point in the history
…tory (elastic#199306)

Closes elastic#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](elastic#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 <[email protected]>
(cherry picked from commit fcc3b06)
  • Loading branch information
jennypavlova committed Nov 12, 2024
1 parent 8fe516a commit 1268d01
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof entityColumnIdsRt>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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*"
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<>
<span>{title}</span>
Expand All @@ -68,8 +72,10 @@ const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipCon

export const getColumns = ({
showAlertsColumn,
showActions,
}: {
showAlertsColumn: boolean;
showActions: boolean;
}): EuiDataGridColumn[] => {
return [
...(showAlertsColumn
Expand Down Expand Up @@ -103,11 +109,24 @@ export const getColumns = ({
// keep it for accessibility purposes
displayAsText: entityLastSeenLabel,
display: (
<CustomHeaderCell title={entityLastSeenLabel} tooltipContent={entityLastSeenToolip} />
<CustomHeaderCell title={entityLastSeenLabel} tooltipContent={entityLastSeenTooltip} />
),
defaultSortDirection: 'desc',
isSortable: true,
schema: 'datetime',
},
...(showActions
? [
{
id: 'actions',
// keep it for accessibility purposes
displayAsText: entityActionsLabel,
display: (
<CustomHeaderCell title={entityActionsLabel} tooltipContent={entityActionsLabel} />
),
initialWidth: 100,
},
]
: []),
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -53,6 +55,8 @@ export function EntitiesGrid({
onChangeSort,
onFilterByType,
}: Props) {
const { getDiscoverRedirectUrl } = useDiscoverRedirect();

const onSort: EuiDataGridSorting['onSort'] = useCallback(
(newSortingColumns) => {
const lastItem = last(newSortingColumns);
Expand All @@ -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(
Expand All @@ -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':
Expand Down Expand Up @@ -127,11 +134,20 @@ export function EntitiesGrid({
);
case ENTITY_DISPLAY_NAME:
return <EntityName entity={entity} />;
case 'actions':
return (
discoverUrl && (
<EntityActions
discoverUrl={discoverUrl}
entityIdentifyingValue={entity[ENTITY_DISPLAY_NAME]}
/>
)
);
default:
return entity[columnId as EntityColumnIds] || '';
}
},
[entities, onFilterByType]
[entities, getDiscoverRedirectUrl, onFilterByType]
);

if (loading) {
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [
<EuiContextMenuItem
data-test-subj="inventoryEntityActionOpenInDiscover"
key={`openInDiscover-${entityIdentifyingValue}`}
color="text"
icon="discoverApp"
href={discoverUrl}
>
{i18n.translate('xpack.inventory.entityActions.discoverLink', {
defaultMessage: 'Open in discover',
})}
</EuiContextMenuItem>,
];

return (
<>
<EuiPopover
isOpen={isPopoverOpen}
panelPaddingSize="none"
anchorPosition="upCenter"
button={
<EuiButtonIcon
data-test-subj={actionButtonTestSubject}
aria-label={i18n.translate(
'xpack.inventory.entityActions.euiButtonIcon.showActionsLabel',
{ defaultMessage: 'Show actions' }
)}
iconType="boxesHorizontal"
color="text"
onClick={togglePopover}
/>
}
closePopover={closePopover}
>
<EuiContextMenuPanel items={actions} size="s" />
</EuiPopover>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ 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';
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,
Expand All @@ -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)) {
Expand Down Expand Up @@ -107,7 +104,7 @@ export function SearchBar() {
refreshSubject$.next();
}
},
[entityTypes, registerSearchSubmittedEvent, searchBarContentSubject$, refreshSubject$]
[searchBarContentSubject$, entityTypes, registerSearchSubmittedEvent, refreshSubject$]
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
*/
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<{
kuery?: string;
entityTypes?: string[];
}>;
refreshSubject$: Subject<void>;
dataView?: DataView;
}

const InventorySearchBarContext = createContext<InventorySearchBarContextType>({
Expand All @@ -21,9 +24,14 @@ const InventorySearchBarContext = createContext<InventorySearchBarContextType>({
});

export function InventorySearchBarContextProvider({ children }: { children: ReactChild }) {
const { dataView } = useAdHocInventoryDataView();
return (
<InventorySearchBarContext.Provider
value={{ searchBarContentSubject$: new Subject(), refreshSubject$: new Subject() }}
value={{
searchBarContentSubject$: new Subject(),
refreshSubject$: new Subject(),
dataView,
}}
>
{children}
</InventorySearchBarContext.Provider>
Expand Down
Loading

0 comments on commit 1268d01

Please sign in to comment.