mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
b3aad140ae
commit
a8c7893458
9 changed files with 304 additions and 11 deletions
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 } : {}),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue