[Asset Inventory] Filter out empty entity.id documents (#224983)

## Summary

This closes #224982

Asset Inventory relies on the `entity.id` field as a unique identifier
for visualizations, grouping, filtering, and flyout functionality.
Currently, documents missing this field are included in the results,
leading to noise, broken interactions, and misleading asset entries.

This PR implements filtering within the Asset Inventory fetchers and UI
components to ensure that only valid documents with `entity.id` are
processed and displayed.


### Screenshot



**Before**
Unexpected behaviour in the Inventory page due to empty `user.name`

<img width="2162" alt="Image"
src="https://github.com/user-attachments/assets/18131bfc-c05e-4165-ab86-fea03b0a1c49"
/>

**After:**


<img width="1985" alt="image"
src="https://github.com/user-attachments/assets/0a3ca7de-b237-4d97-b62c-6fbd665e8cc5"
/>
This commit is contained in:
Paulo Silva 2025-06-24 09:32:20 -07:00 committed by GitHub
parent b3aad140ae
commit a8c7893458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 304 additions and 11 deletions

View file

@ -17,6 +17,7 @@ interface AssetInventorySearchBarProps {
setQuery(v: Partial<AssetsURLQuery>): void;
placeholder?: string;
query: AssetsURLQuery;
isLoading: boolean;
}
export const AssetInventorySearchBar = ({
@ -28,6 +29,7 @@ export const AssetInventorySearchBar = ({
defaultMessage: 'Filter your data using KQL syntax',
}
),
isLoading,
}: AssetInventorySearchBarProps) => {
const { dataView } = useDataViewContext();
const { euiTheme } = useEuiTheme();
@ -47,13 +49,13 @@ export const AssetInventorySearchBar = ({
showDatePicker={false}
indexPatterns={[dataView]}
onQuerySubmit={setQuery}
onFiltersUpdated={(filters: Filter[]) => setQuery({ filters })}
onFiltersUpdated={(newFilters: Filter[]) => setQuery({ filters: newFilters })}
placeholder={placeholder}
query={{
query: query?.query?.query || '',
language: query?.query?.language || 'kuery',
}}
filters={query?.filters || []}
isLoading={isLoading}
/>
</div>
</FiltersGlobal>

View file

@ -18,6 +18,7 @@ import type { AssetsURLQuery } from '../../hooks/use_asset_inventory_url_state/u
import { ASSET_FIELDS } from '../../constants';
import { FilterGroupLoading } from './asset_inventory_filters_loading';
import { ASSET_INVENTORY_RULE_TYPE_IDS } from './asset_inventory_rule_type_ids';
import { addEmptyDataFilter } from '../../utils/add_empty_data_filter';
const DEFAULT_ASSET_INVENTORY_FILTERS: FilterControlConfig[] = [
{
@ -42,13 +43,14 @@ const DEFAULT_ASSET_INVENTORY_FILTERS: FilterControlConfig[] = [
export interface AssetInventoryFiltersProps {
setQuery: (v: Partial<AssetsURLQuery>) => void;
query: AssetsURLQuery;
}
export const AssetInventoryFilters = ({ setQuery }: AssetInventoryFiltersProps) => {
export const AssetInventoryFilters = ({ setQuery, query }: AssetInventoryFiltersProps) => {
const { dataView, dataViewIsLoading } = useDataViewContext();
const spaceId = useSpaceId();
if (!spaceId) {
if (!spaceId || !dataView?.id) {
// TODO Add error handling if no spaceId is found
return null;
}
@ -61,9 +63,11 @@ export const AssetInventoryFilters = ({ setQuery }: AssetInventoryFiltersProps)
);
}
const filters = addEmptyDataFilter(query.filters, dataView.id);
return (
<FilterGroup
dataViewId={dataView.id || null}
dataViewId={dataView.id}
onFiltersChange={(pageFilters: Filter[]) => {
setQuery({ pageFilters });
}}
@ -74,6 +78,8 @@ export const AssetInventoryFilters = ({ setQuery }: AssetInventoryFiltersProps)
spaceId={spaceId}
ControlGroupRenderer={ControlGroupRenderer}
maxControls={4}
query={query.query}
filters={filters}
/>
);
};

View file

@ -15,6 +15,7 @@ import { useMemo } from 'react';
import { useKibana } from '../../../common/lib/kibana';
import { QUERY_KEY_GROUPING_DATA } from '../../constants';
import { useDataViewContext } from '../../hooks/data_view_context';
import { addEmptyDataFilterQuery } from '../../utils/add_empty_data_filter';
type NumberOrNull = number | null;
@ -54,6 +55,13 @@ export const getGroupedAssetsQuery = (query: GroupingQuery, indexPattern?: strin
return {
...query,
query: {
...query?.query,
bool: {
...query?.query?.bool,
must_not: addEmptyDataFilterQuery([]),
},
},
index: indexPattern,
ignore_unavailable: true,
size: 0,

View file

@ -7,11 +7,19 @@
import { getTopAssetsQuery } from './get_top_assets_query';
import { ASSET_FIELDS } from '../../constants';
import { addEmptyDataFilterQuery } from '../../utils/add_empty_data_filter';
jest.mock('../fetch_utils', () => ({
getMultiFieldsSort: jest.fn().mockReturnValue([{ field: 'mocked_sort' }]),
}));
jest.mock('../../utils/add_empty_data_filter', () => ({
addEmptyDataFilterQuery: jest.fn((queryBoolFilter) => [
...queryBoolFilter,
{ match_phrase: { 'entity.id': '' } },
]),
}));
describe('getTopAssetsQuery', () => {
const query = {
bool: {
@ -37,7 +45,7 @@ describe('getTopAssetsQuery', () => {
filter: [{ term: { type: 'aws' } }],
must: [],
should: [],
must_not: [],
must_not: [{ match_phrase: { 'entity.id': '' } }],
}),
}),
sort: [{ field: 'mocked_sort' }],
@ -69,7 +77,34 @@ describe('getTopAssetsQuery', () => {
expect(result.query.bool.filter).toEqual([]);
expect(result.query.bool.must).toEqual([]);
expect(result.query.bool.should).toEqual([]);
expect(result.query.bool.must_not).toEqual([]);
expect(result.query.bool.must_not).toEqual([{ match_phrase: { 'entity.id': '' } }]);
});
it('should add empty entity.id filter to existing must_not filters', () => {
const queryWithExistingMustNot = {
bool: {
filter: [],
must: [],
should: [],
must_not: [{ term: { status: 'inactive' } }],
},
};
const result = getTopAssetsQuery(
{ query: queryWithExistingMustNot, sort, enabled: true },
indexPattern
);
expect(result.query.bool.must_not).toEqual([
{ term: { status: 'inactive' } },
{ match_phrase: { 'entity.id': '' } },
]);
});
it('should call addEmptyDataFilterQuery with the correct parameters', () => {
getTopAssetsQuery({ query, sort, enabled: true }, indexPattern);
expect(addEmptyDataFilterQuery).toHaveBeenCalledWith([]);
});
it('should throw an error if indexPattern is not provided', () => {

View file

@ -8,6 +8,7 @@
import { ASSET_FIELDS } from '../../constants';
import type { UseTopAssetsOptions } from './types';
import { getMultiFieldsSort } from '../fetch_utils';
import { addEmptyDataFilterQuery } from '../../utils/add_empty_data_filter';
export const getTopAssetsQuery = ({ query, sort }: UseTopAssetsOptions, indexPattern?: string) => {
if (!indexPattern) {
@ -54,7 +55,7 @@ export const getTopAssetsQuery = ({ query, sort }: UseTopAssetsOptions, indexPat
filter: [...(query?.bool?.filter ?? [])],
should: [...(query?.bool?.should ?? [])],
must: [...(query?.bool?.must ?? [])],
must_not: [...(query?.bool?.must_not ?? [])],
must_not: addEmptyDataFilterQuery([...(query?.bool?.must_not ?? [])]),
},
},
sort: getMultiFieldsSort(sort),

View file

@ -19,6 +19,7 @@ import { useKibana } from '../../common/lib/kibana';
import { ASSET_FIELDS, MAX_ASSETS_TO_LOAD, QUERY_KEY_GRID_DATA } from '../constants';
import { getRuntimeMappingsFromSort, getMultiFieldsSort } from './fetch_utils';
import { useDataViewContext } from './data_view_context';
import { addEmptyDataFilterQuery } from '../utils/add_empty_data_filter';
interface UseAssetsOptions extends BaseEsQuery {
sort: string[][];
@ -39,6 +40,7 @@ const getAssetsQuery = (
if (!indexPattern) {
throw new Error('Index pattern is required');
}
return {
index: indexPattern,
sort: getMultiFieldsSort(sort),
@ -53,7 +55,7 @@ const getAssetsQuery = (
bool: {
...query?.bool,
filter: [...(query?.bool?.filter ?? [])],
must_not: [...(query?.bool?.must_not ?? [])],
must_not: addEmptyDataFilterQuery([...(query?.bool?.must_not ?? [])]),
},
},
...(pageParam ? { from: pageParam } : {}),

View file

@ -88,14 +88,21 @@ const AllAssetsComponent = () => {
enabled: !queryError,
});
// Todo: Improve to a dedicated loading state that's not dependent on chart data
const isSearchBarLoading = isLoadingChartData || isFetchingChartData;
return (
<I18nProvider>
<AssetInventorySearchBar query={urlQuery} setQuery={setUrlQuery} />
<AssetInventorySearchBar
query={urlQuery}
setQuery={setUrlQuery}
isLoading={isSearchBarLoading}
/>
<EuiPageTemplate.Section>
<AssetInventoryTitle />
<EuiSpacer size="l" />
<OnboardingSuccessCallout />
<AssetInventoryFilters setQuery={setUrlQuery} />
<AssetInventoryFilters query={urlQuery} setQuery={setUrlQuery} />
<EuiSpacer size="l" />
<AssetInventoryBarChart
isLoading={isLoadingChartData}

View file

@ -0,0 +1,189 @@
/*
* 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 type { Filter } from '@kbn/es-query';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { addEmptyDataFilter, addEmptyDataFilterQuery } from './add_empty_data_filter';
import { ASSET_FIELDS } from '../constants';
describe('add_empty_data_filter', () => {
describe('addEmptyDataFilter', () => {
const mockIndex = 'test-index';
const mockFilters: Filter[] = [
{
meta: {
key: 'test.field',
index: mockIndex,
negate: false,
type: 'phrase',
params: { query: 'test-value' },
},
query: {
match_phrase: {
'test.field': 'test-value',
},
},
},
];
it('should add empty entity.id filter to existing filters array', () => {
const result = addEmptyDataFilter(mockFilters, mockIndex);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(mockFilters[0]); // Original filter should be preserved
// Check the added empty data filter
const addedFilter = result[1];
expect(addedFilter.meta).toEqual({
key: ASSET_FIELDS.ENTITY_ID,
index: mockIndex,
negate: true,
type: 'phrase',
params: {
query: '',
},
});
expect(addedFilter.query).toEqual({
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
});
});
it('should add empty entity.id filter to empty filters array', () => {
const result = addEmptyDataFilter([], mockIndex);
expect(result).toHaveLength(1);
const addedFilter = result[0];
expect(addedFilter.meta).toEqual({
key: ASSET_FIELDS.ENTITY_ID,
index: mockIndex,
negate: true,
type: 'phrase',
params: {
query: '',
},
});
expect(addedFilter.query).toEqual({
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
});
});
it('should work with different index names', () => {
const customIndex = 'custom-asset-index';
const result = addEmptyDataFilter([], customIndex);
expect(result).toHaveLength(1);
expect(result[0].meta.index).toBe(customIndex);
});
it('should not mutate the original filters array', () => {
const originalFilters = [...mockFilters];
const result = addEmptyDataFilter(mockFilters, mockIndex);
expect(mockFilters).toEqual(originalFilters); // Original array should be unchanged
expect(result).not.toBe(mockFilters); // Should return a new array
});
it('should create filter with correct entity.id field from constants', () => {
const result = addEmptyDataFilter([], mockIndex);
const addedFilter = result[0];
expect(addedFilter.meta.key).toBe(ASSET_FIELDS.ENTITY_ID);
expect(Object.keys(addedFilter?.query?.match_phrase)).toContain(ASSET_FIELDS.ENTITY_ID);
expect(addedFilter?.query?.match_phrase?.[ASSET_FIELDS.ENTITY_ID]).toBe('');
});
});
describe('addEmptyDataFilterQuery', () => {
const mockQueryBoolFilter: QueryDslQueryContainer[] = [
{
term: {
'test.field': 'test-value',
},
},
{
range: {
'@timestamp': {
gte: '2023-01-01',
lte: '2023-12-31',
},
},
},
];
it('should add empty entity.id match_phrase query to existing query bool filter array', () => {
const result = addEmptyDataFilterQuery(mockQueryBoolFilter);
expect(result).toHaveLength(3);
expect(result[0]).toEqual(mockQueryBoolFilter[0]); // Original queries should be preserved
expect(result[1]).toEqual(mockQueryBoolFilter[1]);
// Check the added empty data filter query
const addedQuery = result[2];
expect(addedQuery).toEqual({
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
});
});
it('should add empty entity.id match_phrase query to empty query bool filter array', () => {
const result = addEmptyDataFilterQuery([]);
expect(result).toHaveLength(1);
const addedQuery = result[0];
expect(addedQuery).toEqual({
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
});
});
it('should not mutate the original query bool filter array', () => {
const originalQueryBoolFilter = [...mockQueryBoolFilter];
const result = addEmptyDataFilterQuery(mockQueryBoolFilter);
expect(mockQueryBoolFilter).toEqual(originalQueryBoolFilter); // Original array should be unchanged
expect(result).not.toBe(mockQueryBoolFilter); // Should return a new array
});
it('should create query with correct entity.id field from constants', () => {
const result = addEmptyDataFilterQuery([]);
const addedQuery = result[0];
expect(addedQuery).toHaveProperty('match_phrase');
expect(Object.keys(addedQuery?.match_phrase ?? {})).toContain(ASSET_FIELDS.ENTITY_ID);
expect(addedQuery?.match_phrase?.[ASSET_FIELDS.ENTITY_ID]).toBe('');
});
it('should handle various query types in the input array', () => {
const mixedQueries: QueryDslQueryContainer[] = [
{ term: { status: 'active' } },
{ bool: { must: [{ term: { type: 'asset' } }] } },
{ exists: { field: 'entity.name' } },
{ wildcard: { name: 'test*' } },
];
const result = addEmptyDataFilterQuery(mixedQueries);
expect(result).toHaveLength(5);
// All original queries should be preserved
mixedQueries.forEach((query, index) => {
expect(result[index]).toEqual(query);
});
// The new query should be added at the end
expect(result[4]).toEqual({
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
});
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 type { Filter } from '@kbn/es-query';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ASSET_FIELDS } from '../constants';
/**
* This function adds an empty filter object to the filters array to remove empty entity.id data
*/
export const addEmptyDataFilter = (filters: Filter[], index: string) => {
return [
...filters,
{
meta: {
key: ASSET_FIELDS.ENTITY_ID,
index,
negate: true,
type: 'phrase',
params: {
query: '',
},
},
query: {
match_phrase: {
[ASSET_FIELDS.ENTITY_ID]: '',
},
},
},
];
};
/**
* This function adds an empty filter object to the query bool filter array to remove empty entity.id data
*/
export const addEmptyDataFilterQuery = (queryBoolFilter: QueryDslQueryContainer[]) => {
const filterQuery = { match_phrase: { [ASSET_FIELDS.ENTITY_ID]: '' } };
return [...queryBoolFilter, filterQuery];
};