mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
8dc6da53cf
commit
84efdaa330
16 changed files with 566 additions and 41 deletions
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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))
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue