[Infrastructure UI] Hosts View: Filtering by metadata functionality (#155170)

Closes #151010

## Summary

This PR adds filtering functionality to the metadata table. After some
discussions there are changes in the design - see the [last
comments](https://github.com/elastic/kibana/issues/151010#issuecomment-1513005095)

## Additional changes

As the filters will change the order I changed the id to not depend on
the table index anymore (used os instead) inside [hosts table

](https://github.com/elastic/kibana/pull/155170/files#diff-168ba138bc6696100078e3e9cbc438ed7646d6336e9ef85a9c88553c9d8956f5)

## Testing
- Open the single host view for one of the hosts available in the table
- Inside the flyout select the Metadata tab
- Click on any of the available filter icons to add a filter 
- Check if the filter is applied and a toast is displayed
- Click on the same filter icon to remove it and check that it is
removed
- If there are other filters applied they should combined with the
metadata filters (so if filter A is added and then a metadata filter is
applied both filters should be visible)



https://user-images.githubusercontent.com/14139027/232837814-7292c7ec-8b63-4172-bcd9-2d49daf3b145.mov
This commit is contained in:
jennypavlova 2023-04-19 14:21:26 +02:00 committed by GitHub
parent ec39987b99
commit 25df1feee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 217 additions and 6 deletions

View file

@ -0,0 +1,120 @@
/*
* 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, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import { buildMetadataFilter } from './build_metadata_filter';
import { useMetricsDataViewContext } from '../../../hooks/use_data_view';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
interface AddMetadataFilterButtonProps {
item: {
name: string;
value: string | string[] | undefined;
};
}
const filterAddedToastTitle = i18n.translate(
'xpack.infra.hostsViewPage.flyout.metadata.filterAdded',
{
defaultMessage: 'Filter was added',
}
);
export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) => {
const { dataView } = useMetricsDataViewContext();
const { searchCriteria } = useUnifiedSearchContext();
const {
services: {
data: {
query: { filterManager: filterManagerService },
},
notifications: { toasts: toastsService },
},
} = useKibanaContextForPlugin();
const existingFilter = useMemo(
() => searchCriteria.filters.find((filter) => filter.meta.key === item.name),
[item.name, searchCriteria.filters]
);
const handleAddFilter = () => {
const newFilter = buildMetadataFilter({
field: item.name,
value: item.value ?? '',
dataView,
negate: false,
});
if (newFilter) {
filterManagerService.addFilters(newFilter);
toastsService.addSuccess({
title: filterAddedToastTitle,
toastLifeTimeMs: 10000,
});
}
};
if (existingFilter) {
return (
<span>
<EuiToolTip
content={i18n.translate(
'xpack.infra.hostsViewPage.flyout.metadata.setRemoveFilterTooltip',
{
defaultMessage: 'Remove filter',
}
)}
>
<EuiButtonIcon
size="s"
color="text"
iconType="filter"
display="base"
data-test-subj="hostsView-flyout-metadata-remove-filter"
aria-label={i18n.translate(
'xpack.infra.hostsViewPage.flyout.metadata.filterAriaLabel',
{
defaultMessage: 'Filter',
}
)}
onClick={() => filterManagerService.removeFilter(existingFilter)}
/>
</EuiToolTip>
</span>
);
}
return (
<span>
<EuiToolTip
content={i18n.translate(
'xpack.infra.hostsViewPage.flyout.metadata.setFilterByValueTooltip',
{
defaultMessage: 'Filter by value',
}
)}
>
<EuiButtonIcon
color="primary"
size="s"
iconType="filter"
data-test-subj="hostsView-flyout-metadata-add-filter"
aria-label={i18n.translate(
'xpack.infra.hostsViewPage.flyout.metadata.AddFilterAriaLabel',
{
defaultMessage: 'Add Filter',
}
)}
onClick={handleAddFilter}
/>
</EuiToolTip>
</span>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 { buildPhrasesFilter, buildPhraseFilter, FilterStateStore } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/common';
export function buildMetadataFilter({
field,
value,
dataView,
negate = false,
}: {
value: string | Array<string | number>;
negate: boolean;
field: string;
dataView: DataView | undefined;
}) {
if (!dataView) {
return null;
}
const indexField = dataView.getFieldByName(field)!;
const areMultipleValues = Array.isArray(value) && value.length > 1;
const filter = areMultipleValues
? buildPhrasesFilter(indexField, value, dataView)
: buildPhraseFilter(indexField, Array.isArray(value) ? value[0] : value, dataView);
filter.meta.type = areMultipleValues ? 'phrases' : 'phrase';
filter.$state = { store: 'appState' as FilterStateStore.APP_STATE };
filter.meta.value = Array.isArray(value)
? !areMultipleValues
? `${value[0]}`
: undefined
: value;
filter.meta.key = field;
filter.meta.alias = null;
filter.meta.negate = negate;
filter.meta.disabled = false;
return filter;
}

View file

@ -20,6 +20,7 @@ import useToggle from 'react-use/lib/useToggle';
import { debounce } from 'lodash';
import { Query } from '@elastic/eui';
import { useHostFlyoutOpen } from '../../../hooks/use_host_flyout_open_url_state';
import { AddMetadataFilterButton } from './add_metadata_filter_button';
interface Row {
name: string;
@ -117,10 +118,19 @@ export const Table = (props: Props) => {
{
field: 'value',
name: VALUE_LABEL,
width: '65%',
width: '55%',
sortable: false,
render: (_name: string, item: Row) => <ExpandableContent values={item.value} />,
},
{
field: 'value',
name: 'Actions',
sortable: false,
showOnHover: true,
render: (_name: string, item: Row) => {
return <AddMetadataFilterButton item={item} />;
},
},
],
[]
);
@ -132,6 +142,7 @@ export const Table = (props: Props) => {
responsive={false}
columns={columns}
items={rows}
rowProps={{ className: 'euiTableRow-hasActions' }}
search={search}
loading={loading}
error={searchError ? `${searchError.message}` : ''}

View file

@ -74,7 +74,7 @@ describe('useHostTable hook', () => {
name: 'host-0',
os: '-',
ip: '',
id: 'host-0-0',
id: 'host-0--',
title: {
cloudProvider: 'aws',
name: 'host-0',
@ -105,7 +105,7 @@ describe('useHostTable hook', () => {
name: 'host-1',
os: 'macOS',
ip: '243.86.94.22',
id: 'host-1-1',
id: 'host-1-macOS',
title: {
cloudProvider: null,
name: 'host-1',

View file

@ -50,8 +50,8 @@ const formatMetric = (type: SnapshotMetricInput['type'], value: number | undefin
};
const buildItemsList = (nodes: SnapshotNode[]) => {
return nodes.map(({ metrics, path, name }, index) => ({
id: `${name}-${index}`,
return nodes.map(({ metrics, path, name }) => ({
id: `${name}-${path.at(-1)?.os ?? '-'}`,
name,
os: path.at(-1)?.os ?? '-',
ip: path.at(-1)?.ip ?? '',

View file

@ -239,9 +239,24 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await logoutAndDeleteReadOnlyUser();
});
it('should render metadata tab', async () => {
it('should render metadata tab, add and remove filter', async () => {
const metadataTab = await pageObjects.infraHostsView.getMetadataTabName();
expect(metadataTab).to.contain('Metadata');
await pageObjects.infraHostsView.clickAddMetadataFilter();
await pageObjects.infraHome.waitForLoading();
// Add Filter
const addedFilter = await pageObjects.infraHostsView.getAppliedFilter();
expect(addedFilter).to.contain('host.architecture: arm64');
const removeFilterExists = await pageObjects.infraHostsView.getRemoveFilterExist();
expect(removeFilterExists).to.be(true);
// Remove filter
await pageObjects.infraHostsView.clickRemoveMetadataFilter();
await pageObjects.infraHome.waitForLoading();
const removeFilterShouldNotExist = await pageObjects.infraHostsView.getRemoveFilterExist();
expect(removeFilterShouldNotExist).to.be(false);
});
it('should navigate to Uptime after click', async () => {

View file

@ -44,6 +44,14 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
return testSubjects.click('hostsView-flyout-apm-services-link');
},
async clickAddMetadataFilter() {
return testSubjects.click('hostsView-flyout-metadata-add-filter');
},
async clickRemoveMetadataFilter() {
return testSubjects.click('hostsView-flyout-metadata-remove-filter');
},
async getHostsLandingPageDisabled() {
const container = await testSubjects.find('hostView-no-enable-access');
const containerText = await container.getVisibleText();
@ -142,6 +150,17 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
return tabTitle.getVisibleText();
},
async getAppliedFilter() {
const filter = await testSubjects.find(
"filter-badge-'host.architecture: arm64' filter filter-enabled filter-key-host.architecture filter-value-arm64 filter-unpinned filter-id-0"
);
return filter.getVisibleText();
},
async getRemoveFilterExist() {
return testSubjects.exists('hostsView-flyout-metadata-remove-filter');
},
async getProcessesTabContentTitle(index: number) {
const processesListElements = await testSubjects.findAll('infraProcessesSummaryTableItem');
return processesListElements[index].findByCssSelector('dt');