[Security Solution][Alerts] Provide more information about rule exception behavior before creation (#149149)

## Summary

These changes surface mapping issues when exceptions are created. We
gonna warn the user about type conflicts and unmapped indices.

Tooltip warning inside the field selection dropdown menu:

<img width="2020" alt="Screenshot 2023-01-18 at 19 01 44"
src="https://user-images.githubusercontent.com/2700761/213261684-61d21068-12bc-408f-8d20-1a196e0719a7.png">

Warning text underneath the dropdown menu when user picks the field
which has mapping issues:


https://user-images.githubusercontent.com/2700761/215467838-5d39ff75-3a2e-44ef-ba89-57cd3975310c.mov

Main ticket #146845

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ievgen Sorokopud 2023-02-06 15:08:47 +01:00 committed by GitHub
parent 8dc6da53cf
commit 84efdaa330
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 566 additions and 41 deletions

View file

@ -27,6 +27,7 @@ export const FieldComponent: React.FC<FieldProps> = ({
placeholder,
selectedField,
acceptsCustomOptions = false,
showMappingConflicts = false,
}): JSX.Element => {
const {
isInvalid,
@ -44,6 +45,7 @@ export const FieldComponent: React.FC<FieldProps> = ({
isRequired,
selectedField,
fieldInputWidth,
showMappingConflicts,
onChange,
});
@ -68,6 +70,7 @@ export const FieldComponent: React.FC<FieldProps> = ({
values: { searchValuePlaceholder: '{searchValue}' },
})}
fullWidth
renderOption={renderFields}
/>
);
}

View file

@ -7,6 +7,7 @@
*/
import { DataViewBase, DataViewFieldBase } from '@kbn/es-query';
import { FieldConflictsInfo } from '@kbn/securitysolution-list-utils';
import { GetGenericComboBoxPropsReturn } from '../get_generic_combo_box_props';
export interface FieldProps extends FieldBaseProps {
@ -15,6 +16,7 @@ export interface FieldProps extends FieldBaseProps {
isLoading: boolean;
placeholder: string;
acceptsCustomOptions?: boolean;
showMappingConflicts?: boolean;
}
export interface FieldBaseProps {
indexPattern: DataViewBase | undefined;
@ -22,6 +24,7 @@ export interface FieldBaseProps {
isRequired?: boolean;
selectedField?: DataViewFieldBase | undefined;
fieldInputWidth?: number;
showMappingConflicts?: boolean;
onChange: (a: DataViewFieldBase[]) => void;
}
@ -32,6 +35,7 @@ export interface ComboBoxFields {
export interface GetFieldComboBoxPropsReturn extends GetGenericComboBoxPropsReturn {
disabledLabelTooltipTexts: { [label: string]: string };
mappingConflictsTooltipInfo: { [label: string]: FieldConflictsInfo[] };
}
export interface DataViewField extends DataViewFieldBase {

View file

@ -7,10 +7,18 @@
*/
import React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { EuiComboBoxOptionOption, EuiToolTip } from '@elastic/eui';
import {
EuiComboBoxOptionOption,
EuiIcon,
EuiSpacer,
EuiToolTip,
useEuiPaddingSize,
} from '@elastic/eui';
import { DataViewBase, DataViewFieldBase } from '@kbn/es-query';
import { FieldConflictsInfo, getMappingConflictsInfo } from '@kbn/securitysolution-list-utils';
import { getGenericComboBoxProps } from '../get_generic_combo_box_props';
import * as i18n from '../translations';
import {
ComboBoxFields,
DataViewField,
@ -72,6 +80,22 @@ const getDisabledLabelTooltipTexts = (fields: ComboBoxFields) => {
);
return disabledLabelTooltipTexts;
};
const getMappingConflictsTooltipInfo = (fields: ComboBoxFields) => {
const mappingConflictsTooltipInfo = fields.availableFields.reduce(
(acc: { [label: string]: FieldConflictsInfo[] }, field: DataViewField) => {
const conflictsInfo = getMappingConflictsInfo(field);
if (!conflictsInfo) {
return acc;
}
acc[field.name] = conflictsInfo;
return acc;
},
{}
);
return mappingConflictsTooltipInfo;
};
const getComboBoxProps = (fields: ComboBoxFields): GetFieldComboBoxPropsReturn => {
const { availableFields, selectedFields } = fields;
@ -81,9 +105,11 @@ const getComboBoxProps = (fields: ComboBoxFields): GetFieldComboBoxPropsReturn =
selectedOptions: selectedFields,
});
const disabledLabelTooltipTexts = getDisabledLabelTooltipTexts(fields);
const mappingConflictsTooltipInfo = getMappingConflictsTooltipInfo(fields);
return {
...genericProps,
disabledLabelTooltipTexts,
mappingConflictsTooltipInfo,
};
};
@ -93,11 +119,13 @@ export const useField = ({
isRequired,
selectedField,
fieldInputWidth,
showMappingConflicts,
onChange,
}: FieldBaseProps) => {
const [touched, setIsTouched] = useState(false);
const [customOption, setCustomOption] = useState<DataViewFieldBase | null>(null);
const sPaddingSize = useEuiPaddingSize('s');
const { availableFields, selectedFields } = useMemo(() => {
const indexPatternsToUse =
@ -107,7 +135,13 @@ export const useField = ({
return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter);
}, [indexPattern, fieldTypeFilter, selectedField, customOption]);
const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo(
const {
comboOptions,
labels,
selectedComboOptions,
disabledLabelTooltipTexts,
mappingConflictsTooltipInfo,
} = useMemo(
() => getComboBoxProps({ availableFields, selectedFields }),
[availableFields, selectedFields]
);
@ -168,6 +202,46 @@ export const useField = ({
</EuiToolTip>
);
}
const conflictsInfo = mappingConflictsTooltipInfo[label];
if (showMappingConflicts && conflictsInfo) {
const tooltipContent = (
<>
{i18n.FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION}
{conflictsInfo.map((info) => {
const groupDetails = info.groupedIndices.map(
({ name, count }) =>
`${count > 1 ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(name, count) : name}`
);
return (
<>
<EuiSpacer size="s" />
{`${
info.totalIndexCount > 1
? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(info.type, info.totalIndexCount)
: info.type
}: ${groupDetails.join(', ')}`}
</>
);
})}
</>
);
return (
<EuiToolTip position="bottom" content={tooltipContent}>
<>
{label}
<EuiIcon
tabIndex={0}
type="alert"
title={i18n.FIELD_CONFLICT_INDICES_WARNING_TITLE}
size="s"
css={{ marginLeft: `${sPaddingSize}` }}
/>
</>
</EuiToolTip>
);
}
return label;
};
return {

View file

@ -43,6 +43,26 @@ export const SEE_DOCUMENTATION = i18n.translate('autocomplete.seeDocumentation',
defaultMessage: 'See Documentation',
});
export const FIELD_CONFLICT_INDICES_WARNING_TITLE = i18n.translate(
'autocomplete.conflictIndicesWarning.title',
{
defaultMessage: 'Mapping Conflict',
}
);
export const FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION = i18n.translate(
'autocomplete.conflictIndicesWarning.description',
{
defaultMessage: 'This field is defined as several types across different indices.',
}
);
export const CONFLICT_MULTIPLE_INDEX_DESCRIPTION = (name: string, count: number): string =>
i18n.translate('autocomplete.conflictIndicesWarning.index.description', {
defaultMessage: '{name} ({count} indices)',
values: { count, name },
});
// eslint-disable-next-line import/no-default-export
export default {
LOADING,

View file

@ -0,0 +1,146 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getMappingConflictsInfo } from '.';
describe('Helpers', () => {
describe('getMappingConflictsInfo', () => {
test('it return null if there are not conflicts', () => {
const field = {
name: 'field1',
type: 'string',
};
const conflictsInfo = getMappingConflictsInfo(field);
expect(conflictsInfo).toBeNull();
});
test('it groups ".ds-" data stream indices', () => {
const field = {
name: 'field1',
type: 'conflict',
conflictDescriptions: {
text: [
'.ds-logs-default-2023.01.18-000001',
'.ds-logs-default-2023.01.18-000002',
'.ds-logs-tortilla.process-default-2022.11.20-000011',
'.ds-logs-tortilla.process-default-2022.11.20-000012',
'.ds-logs-tortilla.process-default-2022.11.20-000016',
],
long: [
'.ds-logs-default-2023.01.18-000004',
'.ds-logs-default-2023.01.18-000005',
'partial-.ds-logs-gcp.audit-2021.12.22-000240',
'partial-.ds-logs-gcp.audit-2021.12.22-000242',
],
},
};
const conflictsInfo = getMappingConflictsInfo(field);
expect(conflictsInfo).toEqual([
{
type: 'text',
totalIndexCount: 5,
groupedIndices: [
{ name: 'logs-tortilla.process-default', count: 3 },
{ name: 'logs-default', count: 2 },
],
},
{
type: 'long',
totalIndexCount: 4,
groupedIndices: [
{ name: 'logs-default', count: 2 },
{ name: 'logs-gcp.audit', count: 2 },
],
},
]);
});
test('it groups old ".siem-" indices', () => {
const field = {
name: 'field1',
type: 'conflict',
conflictDescriptions: {
text: [
'.siem-signals-default-000001',
'.siem-signals-default-000002',
'.siem-signals-default-000011',
'.siem-signals-default-000012',
],
unmapped: [
'.siem-signals-default-000004',
'.siem-signals-default-000005',
'.siem-signals-default-000240',
],
},
};
const conflictsInfo = getMappingConflictsInfo(field);
expect(conflictsInfo).toEqual([
{
type: 'text',
totalIndexCount: 4,
groupedIndices: [{ name: '.siem-signals-default', count: 4 }],
},
{
type: 'unmapped',
totalIndexCount: 3,
groupedIndices: [{ name: '.siem-signals-default', count: 3 }],
},
]);
});
test('it groups mixed indices', () => {
const field = {
name: 'field1',
type: 'conflict',
conflictDescriptions: {
boolean: [
'.ds-logs-default-2023.01.18-000001',
'.ds-logs-tortilla.process-default-2022.11.20-000011',
'.ds-logs-tortilla.process-default-2022.11.20-000012',
'.ds-logs-tortilla.process-default-2022.11.20-000016',
'.siem-signals-default-000001',
'.siem-signals-default-000002',
'.siem-signals-default-000012',
'my-own-index-1',
'my-own-index-2',
],
unmapped: [
'.siem-signals-default-000004',
'partial-.ds-logs-gcp.audit-2021.12.22-000240',
'partial-.ds-logs-gcp.audit-2021.12.22-000242',
'my-own-index-3',
],
},
};
const conflictsInfo = getMappingConflictsInfo(field);
expect(conflictsInfo).toEqual([
{
type: 'boolean',
totalIndexCount: 9,
groupedIndices: [
{ name: 'logs-tortilla.process-default', count: 3 },
{ name: '.siem-signals-default', count: 3 },
{ name: 'logs-default', count: 1 },
{ name: 'my-own-index-1', count: 1 },
{ name: 'my-own-index-2', count: 1 },
],
},
{
type: 'unmapped',
totalIndexCount: 4,
groupedIndices: [
{ name: 'logs-gcp.audit', count: 2 },
{ name: '.siem-signals-default', count: 1 },
{ name: 'my-own-index-3', count: 1 },
],
},
]);
});
});
});

View file

@ -53,6 +53,7 @@ import {
import {
BuilderEntry,
CreateExceptionListItemBuilderSchema,
DataViewField,
EmptyEntry,
EmptyNestedEntry,
ExceptionsBuilderExceptionItem,
@ -912,3 +913,87 @@ export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({
export const containsValueListEntry = (items: ExceptionsBuilderExceptionItem[]): boolean =>
items.some((item) => item.entries.some(({ type }) => type === OperatorTypeEnum.LIST));
const getIndexGroupName = (indexName: string): string => {
// Check whether it is a Data Stream index
const dataStreamExp = /.ds-(.*?)-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[0-9]{6}/;
let result = indexName.match(dataStreamExp);
if (result && result.length === 2) {
return result[1];
}
// Check whether it is an old '.siem' index group
const siemSignalsExp = /.siem-(.*?)-[0-9]{6}/;
result = indexName.match(siemSignalsExp);
if (result && result.length === 2) {
return `.siem-${result[1]}`;
}
// Otherwise return index name
return indexName;
};
export interface FieldConflictsInfo {
/**
* Kibana field type
*/
type: string;
/**
* Total count of the indices of this type
*/
totalIndexCount: number;
/**
* Grouped indices info
*/
groupedIndices: Array<{
/**
* Index group name (like '.ds-...' or '.siem-signals-...')
*/
name: string;
/**
* Count of indices in the group
*/
count: number;
}>;
}
export const getMappingConflictsInfo = (field: DataViewField): FieldConflictsInfo[] | null => {
if (!field.conflictDescriptions) {
return null;
}
const conflicts: FieldConflictsInfo[] = [];
for (const [key, value] of Object.entries(field.conflictDescriptions)) {
const groupedIndices: Array<{
name: string;
count: number;
}> = [];
// Group indices and calculate count of indices in each group
const groupedInfo: { [key: string]: number } = {};
value.forEach((index) => {
const groupName = getIndexGroupName(index);
if (!groupedInfo[groupName]) {
groupedInfo[groupName] = 0;
}
groupedInfo[groupName]++;
});
for (const [name, count] of Object.entries(groupedInfo)) {
groupedIndices.push({
name,
count,
});
}
// Sort groups by the indices count
groupedIndices.sort((group1, group2) => {
return group2.count - group1.count;
});
conflicts.push({
type: key,
totalIndexCount: value.length,
groupedIndices,
});
}
return conflicts;
};

View file

@ -117,3 +117,7 @@ export const exceptionListAgnosticSavedObjectType = EXCEPTION_LIST_NAMESPACE_AGN
export type SavedObjectType =
| typeof EXCEPTION_LIST_NAMESPACE
| typeof EXCEPTION_LIST_NAMESPACE_AGNOSTIC;
export interface DataViewField extends DataViewFieldBase {
conflictDescriptions?: Record<string, string[]>;
}

View file

@ -306,6 +306,29 @@ export const fields: FieldSpec[] = [
readFromDocValues: false,
subType: { nested: { path: 'nestedField.nestedChild' } },
},
{
name: 'mapping issues',
type: 'conflict',
esTypes: ['text', 'unmapped'],
count: 0,
scripted: false,
searchable: true,
aggregatable: false,
readFromDocValues: false,
conflictDescriptions: {
text: [
'.siem-signals-default-000001',
'.siem-signals-default-000002',
'.siem-signals-default-000011',
'.siem-signals-default-000012',
],
unmapped: [
'.siem-signals-default-000004',
'.siem-signals-default-000005',
'.siem-signals-default-000240',
],
},
},
];
export const getField = (name: string) => fields.find((field) => field.name === name) as FieldSpec;

View file

@ -156,6 +156,40 @@ describe('BuilderEntryItem', () => {
expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy();
});
test('it render mapping issues warning text when field has mapping conflicts', () => {
wrapper = mount(
<BuilderEntryItem
autocompleteService={autocompleteStartMock}
entry={{
correspondingKeywordField: undefined,
entryIndex: 0,
field: getField('mapping issues'),
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: '1234',
}}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
}}
listType="detection"
onChange={jest.fn()}
setErrorsExist={jest.fn()}
setWarningsExist={jest.fn()}
showLabel
allowCustomOptions
/>
);
expect(wrapper.find('.euiFormHelpText.euiFormRow__text').text()).toMatch(
/This field is defined as several types across different indices./
);
});
test('it renders field values correctly when operator is "isOperator"', () => {
wrapper = mount(
<BuilderEntryItem

View file

@ -7,7 +7,16 @@
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui';
import {
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiIconTip,
EuiSpacer,
useEuiPaddingSize,
} from '@elastic/eui';
import styled from 'styled-components';
import {
ExceptionListType,
@ -27,6 +36,7 @@ import {
getEntryOnOperatorChange,
getEntryOnWildcardChange,
getFilteredIndexPatterns,
getMappingConflictsInfo,
getOperatorOptions,
} from '@kbn/securitysolution-list-utils';
import {
@ -96,6 +106,8 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
operatorsList,
allowCustomOptions = false,
}): JSX.Element => {
const sPaddingSize = useEuiPaddingSize('s');
const handleError = useCallback(
(err: boolean): void => {
setErrorsExist(err);
@ -194,49 +206,82 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
onChange={handleFieldChange}
acceptsCustomOptions={entry.nested == null}
data-test-subj="exceptionBuilderEntryField"
showMappingConflicts={true}
/>
);
if (isFirst) {
const warningIconCss = { marginRight: `${sPaddingSize}` };
const getMappingConflictsWarning = (field: DataViewFieldBase): React.ReactNode | null => {
const conflictsInfo = getMappingConflictsInfo(field);
if (!conflictsInfo) {
return null;
}
return (
<EuiFormRow
fullWidth
label={i18n.FIELD}
helpText={
entry.nested == null && allowCustomOptions
? i18n.CUSTOM_COMBOBOX_OPTION_TEXT
: undefined
}
data-test-subj="exceptionBuilderEntryFieldFormRow"
>
{comboBox}
</EuiFormRow>
<>
<EuiSpacer size="s" />
<EuiAccordion
id={'1'}
buttonContent={
<>
<EuiIcon tabIndex={0} type="alert" size="s" css={warningIconCss} />
{i18n.FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION}
</>
}
arrowDisplay="none"
>
{conflictsInfo.map((info) => {
const groupDetails = info.groupedIndices.map(
({ name, count }) =>
`${count > 1 ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(name, count) : name}`
);
return (
<>
<EuiSpacer size="s" />
{`${
info.totalIndexCount > 1
? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(info.type, info.totalIndexCount)
: info.type
}: ${groupDetails.join(', ')}`}
</>
);
})}
<EuiSpacer size="s" />
</EuiAccordion>
</>
);
} else {
return (
<EuiFormRow
fullWidth
label={''}
helpText={
entry.nested == null && allowCustomOptions
? i18n.CUSTOM_COMBOBOX_OPTION_TEXT
: undefined
}
data-test-subj="exceptionBuilderEntryFieldFormRow"
>
{comboBox}
</EuiFormRow>
};
const customOptionText =
entry.nested == null && allowCustomOptions ? i18n.CUSTOM_COMBOBOX_OPTION_TEXT : undefined;
const helpText =
entry.field?.type !== 'conflict' ? (
customOptionText
) : (
<>
{customOptionText}
{getMappingConflictsWarning(entry.field)}
</>
);
}
return (
<EuiFormRow
fullWidth
label={isFirst ? i18n.FIELD : ''}
helpText={helpText}
data-test-subj="exceptionBuilderEntryFieldFormRow"
>
{comboBox}
</EuiFormRow>
);
},
[
indexPattern,
entry,
listType,
listTypeSpecificIndexPatternFilter,
handleFieldChange,
osTypes,
isDisabled,
handleFieldChange,
sPaddingSize,
allowCustomOptions,
]
);

View file

@ -83,3 +83,16 @@ export const CUSTOM_COMBOBOX_OPTION_TEXT = i18n.translate(
'Select a field from the list. If your field is not available, create a custom one.',
}
);
export const FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION = i18n.translate(
'xpack.lists.exceptions.field.mappingConflict.description',
{
defaultMessage: 'This field is defined as several types across different indices.',
}
);
export const CONFLICT_MULTIPLE_INDEX_DESCRIPTION = (name: string, count: number): string =>
i18n.translate('xpack.lists.exceptions.field.index.description', {
defaultMessage: '{name} ({count} indices)',
values: { count, name },
});

View file

@ -47,7 +47,18 @@ export const getIndexFields = memoizeOne(
fields && fields.length > 0
? {
fields: fields.map((field) =>
pick(['name', 'searchable', 'type', 'aggregatable', 'esTypes', 'subType'], field)
pick(
[
'name',
'searchable',
'type',
'aggregatable',
'esTypes',
'subType',
'conflictDescriptions',
],
field
)
),
title,
}
@ -99,7 +110,8 @@ interface FetchIndexReturn {
export const useFetchIndex = (
indexNames: string[],
onlyCheckIfIndicesExist: boolean = false,
strategy: string = 'indexFields'
strategy: string = 'indexFields',
includeUnmapped: boolean = false
): [boolean, FetchIndexReturn] => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
@ -122,7 +134,7 @@ export const useFetchIndex = (
setLoading(true);
searchSubscription$.current = data.search
.search<IndexFieldsStrategyRequest<'indices'>, IndexFieldsStrategyResponse>(
{ indices: iNames, onlyCheckIfIndicesExist },
{ indices: iNames, onlyCheckIfIndicesExist, includeUnmapped },
{
abortSignal: abortCtrl.current.signal,
strategy,
@ -133,7 +145,9 @@ export const useFetchIndex = (
if (isCompleteResponse(response)) {
Promise.resolve().then(() => {
ReactDOM.unstable_batchedUpdates(() => {
const stringifyIndices = response.indicesExist.sort().join();
const stringifyIndices = `${response.indicesExist
.sort()
.join()} (includeUnmapped: ${includeUnmapped})`;
previousIndexesName.current = response.indicesExist;
const { browserFields } = getDataViewStateFromIndexFields(
@ -170,7 +184,16 @@ export const useFetchIndex = (
abortCtrl.current.abort();
asyncSearch();
},
[data.search, addError, addWarning, onlyCheckIfIndicesExist, setLoading, setState, strategy]
[
data.search,
addError,
addWarning,
onlyCheckIfIndicesExist,
includeUnmapped,
setLoading,
setState,
strategy,
]
);
useEffect(() => {

View file

@ -80,8 +80,12 @@ export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExcep
}
}, [jobs, isMLRule, memoDataViewId, memoNonDataViewIndexPatterns]);
const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] =
useFetchIndex(memoRuleIndices);
const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex(
memoRuleIndices,
false,
'indexFields',
true
);
// Data view logic
const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState<DataViewBase | null>(null);
@ -93,8 +97,15 @@ export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExcep
if (activeSpaceId !== '' && memoDataViewId) {
setDataViewLoading(true);
const dv = await data.dataViews.get(memoDataViewId);
const fieldsWithUnmappedInfo = await data.dataViews.getFieldsForIndexPattern(dv, {
pattern: '',
includeUnmapped: true,
});
setDataViewLoading(false);
setDataViewIndexPatterns(dv);
setDataViewIndexPatterns({
...dv,
fields: fieldsWithUnmappedInfo,
});
}
};

View file

@ -40,6 +40,7 @@ export type BeatFields = Record<string, FieldInfo>;
export interface IndexFieldsStrategyRequestByIndices extends IEsSearchRequest {
indices: string[];
onlyCheckIfIndicesExist: boolean;
includeUnmapped?: boolean;
}
export interface IndexFieldsStrategyRequestById extends IEsSearchRequest {
dataViewId: string;

View file

@ -424,6 +424,33 @@ describe('Fields Provider', () => {
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual([]);
});
it('should search index fields with includeUnmapped option', async () => {
const indices = ['some-index-pattern-*'];
const request = {
indices,
includeUnmapped: true,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({
pattern: indices[0],
fieldCapsOptions: {
allow_no_indices: true,
includeUnmapped: true,
},
});
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
});
});
});

View file

@ -8,6 +8,7 @@
import { from } from 'rxjs';
import isEmpty from 'lodash/isEmpty';
import get from 'lodash/get';
import deepmerge from 'deepmerge';
import { ElasticsearchClient, StartServicesAccessor } from '@kbn/core/server';
import {
DataViewsServerPluginStart,
@ -153,13 +154,18 @@ export const requestIndexFieldSearch = async (
const fieldDescriptor = (
await Promise.all(
indicesExist.map(async (index, n) => {
const fieldCapsOptions = request.includeUnmapped
? { includeUnmapped: true, allow_no_indices: true }
: undefined;
if (index.startsWith('.alerts-observability') || useInternalUser) {
return indexPatternsFetcherAsInternalUser.getFieldsForWildcard({
pattern: index,
fieldCapsOptions,
});
}
return indexPatternsFetcherAsCurrentUser.getFieldsForWildcard({
pattern: index,
fieldCapsOptions,
});
})
)
@ -264,7 +270,7 @@ export const createFieldItem = (
};
/**
* Iterates over each field, adds description, category, and indexes (index alias)
* Iterates over each field, adds description, category, conflictDescriptions, and indexes (index alias)
*
* This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs
* in size at a time when being called. This function should be as optimized as possible
@ -299,6 +305,12 @@ export const formatIndexFields = async (
if (isEmpty(accumulator[alreadyExistingIndexField].description)) {
accumulator[alreadyExistingIndexField].description = item.description;
}
if (item.conflictDescriptions) {
accumulator[alreadyExistingIndexField].conflictDescriptions = deepmerge(
existingIndexField.conflictDescriptions ?? {},
item.conflictDescriptions
);
}
accumulator[alreadyExistingIndexField].indexes = Array.from(
new Set(existingIndexField.indexes.concat(item.indexes))
);