[Security Solution] Insight filter builder form as markdown plugin (#150363)

## Summary

This pr expands upon the work done in
https://github.com/elastic/kibana/pull/145240 to make use of the filters
builder form from unified_search to serialize filters into a markdown
compatible string, so that investigation guides, timeline notes or any
other place where text is parsed as markdown can make use of standard
kibana filters and view a count of the matching documents at a glance,
and open the entire set in timeline as well. These are generally
converted to timeline data providers to enable drag and drop query
building, however this is not supported for filters of range type, so
regular kibana filters are used in that case for now.

![Screenshot 2023-02-06 at 3 46 15
PM](https://user-images.githubusercontent.com/56408403/217081398-7e0d263f-cdb5-48eb-9328-f01a63af768e.png)
![Screenshot 2023-02-06 at 3 49 46
PM](https://user-images.githubusercontent.com/56408403/217082554-389edad5-89ff-4d86-bd31-c2085073b39a.png)
![Screenshot 2023-02-06 at 3 50 15
PM](https://user-images.githubusercontent.com/56408403/217082658-7ef8af2b-ba7f-4676-a775-e8c550adeee6.png)
![Screenshot 2023-02-06 at 3 50 54
PM](https://user-images.githubusercontent.com/56408403/217082770-9bacbd2a-fbee-4d1f-b6f5-b7d97ed2e3ca.png)
![Screenshot 2023-02-06 at 3 51 16
PM](https://user-images.githubusercontent.com/56408403/217082842-7494b1ac-6687-426e-8e85-6fec0afcc70e.png)
![Screenshot 2023-02-06 at 3 53 48
PM](https://user-images.githubusercontent.com/56408403/217083273-f9acfa30-a156-4146-86a2-5ebb84f4ecd0.png)
![Screenshot 2023-02-06 at 3 54 30
PM](https://user-images.githubusercontent.com/56408403/217083407-1a8af419-6c09-4558-9c18-11604cb7e796.png)




### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Kevin Qualters 2023-02-07 09:17:01 -05:00 committed by GitHub
parent 7e9a9bcc99
commit 13d1f398ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 868 additions and 261 deletions

View file

@ -36,6 +36,7 @@ const createStartContract = (): Start => {
IndexPatternSelect: jest.fn(),
SearchBar: jest.fn().mockReturnValue(null),
AggregateQuerySearchBar: jest.fn().mockReturnValue(null),
FiltersBuilderLazy: jest.fn(),
},
};
};

View file

@ -24,6 +24,7 @@ import type {
import { createFilterAction } from './actions/apply_filter_action';
import { createUpdateFilterReferencesAction } from './actions/update_filter_references_action';
import { ACTION_GLOBAL_APPLY_FILTER, UPDATE_FILTER_REFERENCES_ACTION } from './actions';
import { FiltersBuilderLazy } from './filters_builder';
import './index.scss';
@ -92,6 +93,7 @@ export class UnifiedSearchPublicPlugin
IndexPatternSelect: createIndexPatternSelect(dataViews),
SearchBar,
AggregateQuerySearchBar: SearchBar,
FiltersBuilderLazy,
},
autocomplete: autocompleteStart,
};

View file

@ -18,6 +18,7 @@ import { CoreStart, DocLinksStart } from '@kbn/core/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { AutocompleteSetup, AutocompleteStart } from './autocomplete';
import type { IndexPatternSelectProps, StatefulSearchBarProps } from '.';
import type { FiltersBuilderProps } from './filters_builder/filters_builder';
export interface UnifiedSearchSetupDependencies {
uiActions: UiActionsSetup;
@ -46,6 +47,7 @@ export interface UnifiedSearchPublicPluginStartUi {
AggregateQuerySearchBar: <QT extends Query | AggregateQuery = Query>(
props: StatefulSearchBarProps<QT>
) => React.ReactElement;
FiltersBuilderLazy: React.ComponentType<FiltersBuilderProps>;
}
/**

View file

@ -93,7 +93,7 @@ describe('Timeline notes tab', () => {
it('should render insight query from markdown', () => {
addNotesToTimeline(
`!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "type": "parameter" }], [{ "field": "event.category", "value": "network", "type": "literal" }, {"field": "process.pid", "value": "process.pid", "type": "parameter"}]]}}`
`!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "queryType": "phrase", "excluded": "false" }], [{ "field": "event.category", "value": "network", "queryType": "phrase", "excluded": "false" }, {"field": "process.pid", "value": "process.pid", "queryType": "phrase", "excluded": "false"}]]}}`
);
cy.get(MARKDOWN_INVESTIGATE_BUTTON).should('exist');
});

View file

@ -83,7 +83,7 @@ export const useHoverActions = ({
const values = useMemo(() => {
const val = dataProvider.queryMatch.value;
if (typeof val === 'number') {
if (typeof val === 'number' || typeof val === 'boolean') {
return val.toString();
}

View file

@ -23,6 +23,7 @@ export const { uiPlugins, parsingPlugins, processingPlugins } = {
uiPlugins.push(timelineMarkdownPlugin.plugin);
uiPlugins.push(osqueryMarkdownPlugin.plugin);
uiPlugins.push(insightMarkdownPlugin.plugin);
parsingPlugins.push(insightMarkdownPlugin.parser);
parsingPlugins.push(timelineMarkdownPlugin.parser);

View file

@ -69,7 +69,7 @@ describe('insight component renderer', () => {
label={'test label'}
description={'test description'}
providers={
'[[{"field":"event.id","value":"kibana.alert.original_event.id","type":"parameter"}],[{"field":"event.category","value":"network","type":"literal"},{"field":"process.pid","value":"process.pid","type":"parameter"}]]'
'[[{"field":"event.id","value":"{{kibana.alert.original_event.id}}","queryType":"phrase", "excluded": "false"}],[{"field":"event.category","value":"network","queryType":"phrase", "excluded": "false"}},{"field":"process.pid","value":"process.pid","queryType":"phrase", "excluded": "false", "valueType":"number"}}]]'
}
/>
</TestProviders>

View file

@ -5,23 +5,71 @@
* 2.0.
*/
import { pickBy, isEmpty } from 'lodash';
import type { Plugin } from 'unified';
import React, { useContext, useMemo } from 'react';
import React, { useContext, useMemo, useCallback, useState } from 'react';
import type { RemarkTokenizer } from '@elastic/eui';
import { EuiLoadingSpinner, EuiIcon } from '@elastic/eui';
import {
EuiLoadingSpinner,
EuiIcon,
EuiSpacer,
EuiBetaBadge,
EuiCodeBlock,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiModalFooter,
EuiButton,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiSelect,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import { css } from '@emotion/react';
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
import type { DataView } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Filter } from '@kbn/es-query';
import {
FILTERS,
isCombinedFilter,
isRangeFilter,
isPhraseFilter,
isPhrasesFilter,
isExistsFilter,
BooleanRelation,
FilterStateStore,
} from '@kbn/es-query';
import type { PhraseFilterValue } from '@kbn/es-query/src/filters/build_filters';
import { useForm, FormProvider, useController } from 'react-hook-form';
import { useAppToasts } from '../../../../hooks/use_app_toasts';
import { useKibana } from '../../../../lib/kibana';
import { useInsightQuery } from './use_insight_query';
import { useInsightDataProviders } from './use_insight_data_providers';
import { useInsightDataProviders, type Provider } from './use_insight_data_providers';
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
import { InvestigateInTimelineButton } from '../../../event_details/table/investigate_in_timeline_button';
import { getTimeRangeSettings } from '../../../../utils/default_date_settings';
import {
getTimeRangeSettings,
parseDateWithDefault,
DEFAULT_FROM_MOMENT,
DEFAULT_TO_MOMENT,
} from '../../../../utils/default_date_settings';
import type { TimeRange } from '../../../../store/inputs/model';
import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/constants';
import { useSourcererDataView } from '../../../../containers/sourcerer';
import { SourcererScopeName } from '../../../../store/sourcerer/model';
interface InsightComponentProps {
label?: string;
description?: string;
providers?: string;
relativeFrom?: string;
relativeTo?: string;
}
export const parser: Plugin = function () {
@ -95,8 +143,88 @@ export const parser: Plugin = function () {
methods.splice(methods.indexOf('text'), 0, 'insight');
};
const buildPrimitiveProvider = (filter: Filter): Provider => {
const field = filter.meta?.key ?? '';
const excluded = filter.meta?.negate ?? false;
const queryType = filter.meta?.type ?? FILTERS.PHRASE;
const baseFilter = {
field,
excluded,
queryType,
};
if (isRangeFilter(filter)) {
const gte = filter.query.range[field].gte;
const lt = filter.query.range[field].lt;
const value = JSON.stringify({ gte, lt });
return {
...baseFilter,
value,
queryType: filter.meta.type ?? FILTERS.RANGE,
};
} else if (isPhrasesFilter(filter)) {
const typeOfParams: PhraseFilterValue = typeof filter.meta?.params[0];
return {
...baseFilter,
value: JSON.stringify(filter.meta?.params ?? []),
valueType: typeOfParams,
queryType: filter.meta.type ?? FILTERS.PHRASES,
};
} else if (isExistsFilter(filter)) {
return {
...baseFilter,
value: '',
queryType: filter.meta.type ?? FILTERS.EXISTS,
};
} else if (isPhraseFilter(filter)) {
const valueType: PhraseFilterValue = typeof filter.meta?.params?.query;
return {
...baseFilter,
value: filter.meta?.params?.query ?? '',
valueType,
queryType: filter.meta.type ?? FILTERS.PHRASE,
};
} else {
return {
...baseFilter,
value: '',
queryType: FILTERS.PHRASE,
};
}
};
const filtersToInsightProviders = (filters: Filter[]): Provider[][] => {
const providers = [];
for (let index = 0; index < filters.length; index++) {
const filter = filters[index];
if (isCombinedFilter(filter)) {
if (filter.meta.relation === BooleanRelation.AND) {
return filtersToInsightProviders(filter.meta?.params);
} else {
return filter.meta?.params.map((innerFilter) => {
if (isCombinedFilter(innerFilter)) {
return filtersToInsightProviders([innerFilter]).map(([provider]) => provider);
} else {
return [buildPrimitiveProvider(innerFilter)];
}
});
}
} else {
providers.push([buildPrimitiveProvider(filter)]);
}
}
return providers;
};
const resultFormat = '0,0.[000]a';
// receives the configuration from the parser and renders
const InsightComponent = ({ label, description, providers }: InsightComponentProps) => {
const InsightComponent = ({
label,
description,
providers,
relativeFrom,
relativeTo,
}: InsightComponentProps) => {
const { addError } = useAppToasts();
let parsedProviders = [];
try {
@ -111,15 +239,28 @@ const InsightComponent = ({ label, description, providers }: InsightComponentPro
});
}
const { data: alertData } = useContext(BasicAlertDataContext);
const dataProviders = useInsightDataProviders({
const { dataProviders, filters } = useInsightDataProviders({
providers: parsedProviders,
alertData,
});
const { totalCount, isQueryLoading, oldestTimestamp, hasError } = useInsightQuery({
dataProviders,
filters,
});
const timerange: TimeRange = useMemo(() => {
if (oldestTimestamp != null) {
if (relativeFrom && relativeTo) {
const fromStr = relativeFrom;
const toStr = relativeTo;
const from = parseDateWithDefault(fromStr, DEFAULT_FROM_MOMENT).toISOString();
const to = parseDateWithDefault(toStr, DEFAULT_TO_MOMENT, true).toISOString();
return {
kind: 'relative',
from,
to,
fromStr,
toStr,
};
} else if (oldestTimestamp != null) {
return {
kind: 'absolute',
from: oldestTimestamp,
@ -135,24 +276,300 @@ const InsightComponent = ({ label, description, providers }: InsightComponentPro
toStr,
};
}
}, [oldestTimestamp]);
}, [oldestTimestamp, relativeFrom, relativeTo]);
if (isQueryLoading) {
return <EuiLoadingSpinner size="l" />;
} else {
return (
<InvestigateInTimelineButton
asEmptyButton={false}
isDisabled={hasError}
dataProviders={dataProviders}
timeRange={timerange}
keepDataView={true}
data-test-subj="insight-investigate-in-timeline-button"
>
<EuiIcon type="timeline" />
{` ${label} (${totalCount}) - ${description}`}
</InvestigateInTimelineButton>
<>
<InvestigateInTimelineButton
asEmptyButton={false}
isDisabled={hasError}
dataProviders={dataProviders}
filters={filters}
timeRange={timerange}
keepDataView={true}
data-test-subj="insight-investigate-in-timeline-button"
>
<EuiIcon type="timeline" />
{` ${label} (${numeral(totalCount).format(resultFormat)})`}
</InvestigateInTimelineButton>
<div>{description}</div>
</>
);
}
};
export { InsightComponent as renderer };
const InsightEditorComponent = ({
node,
onSave,
onCancel,
}: EuiMarkdownEditorUiPluginEditorProps<InsightComponentProps & { relativeTimerange: string }>) => {
const isEditMode = node != null;
const { sourcererDataView, indexPattern } = useSourcererDataView(SourcererScopeName.default);
const {
unifiedSearch: {
ui: { FiltersBuilderLazy },
},
uiSettings,
} = useKibana().services;
const [providers, setProviders] = useState<Provider[][]>([[]]);
const dateRangeChoices = useMemo(() => {
const settings: Array<{ from: string; to: string; display: string }> = uiSettings.get(
DEFAULT_TIMEPICKER_QUICK_RANGES
);
const emptyValue = { value: '0', text: '' };
return [
emptyValue,
...settings.map(({ display }, index) => {
return {
value: String(index),
text: display,
};
}),
];
}, [uiSettings]);
const kibanaDataProvider = useMemo(() => {
return {
...sourcererDataView,
fields: sourcererDataView?.indexFields,
} as DataView;
}, [sourcererDataView]);
const formMethods = useForm<{
label: string;
description: string;
relativeTimerange?: string;
}>({
defaultValues: {
label: node?.label,
description: node?.description,
relativeTimerange: node?.relativeTimerange || '0',
},
shouldUnregister: true,
});
const labelController = useController({ name: 'label', control: formMethods.control });
const descriptionController = useController({
name: 'description',
control: formMethods.control,
});
const relativeTimerangeController = useController({
name: 'relativeTimerange',
control: formMethods.control,
});
const getTimeRangeSelection = useCallback(
(selection?: string) => {
const selectedOption = dateRangeChoices.find((option) => {
return option.value === selection;
});
if (selectedOption && selectedOption.value !== '0') {
const settingsIndex = Number(selectedOption.value);
const settings: Array<{ from: string; to: string; display: string }> = uiSettings.get(
DEFAULT_TIMEPICKER_QUICK_RANGES
);
return {
relativeFrom: settings[settingsIndex].from,
relativeTo: settings[settingsIndex].to,
};
} else {
return {};
}
},
[dateRangeChoices, uiSettings]
);
const onSubmit = useCallback(() => {
onSave(
`!{insight${JSON.stringify(
pickBy(
{
label: labelController.field.value,
description: descriptionController.field.value,
providers,
...getTimeRangeSelection(relativeTimerangeController.field.value),
},
(value) => !isEmpty(value)
)
)}}`,
{
block: true,
}
);
}, [
onSave,
providers,
labelController.field.value,
descriptionController.field.value,
relativeTimerangeController.field.value,
getTimeRangeSelection,
]);
const onChange = useCallback((filters: Filter[]) => {
setProviders(filtersToInsightProviders(filters));
}, []);
const selectOnChange = useCallback(
(event) => {
relativeTimerangeController.field.onChange(event.target.value);
},
[relativeTimerangeController.field]
);
const filtersStub = useMemo(() => {
const index = indexPattern && indexPattern.getName ? indexPattern.getName() : '*';
return [
{
$state: {
store: FilterStateStore.APP_STATE,
},
meta: {
disabled: false,
negate: false,
alias: null,
index,
},
},
];
}, [indexPattern]);
return (
<>
<EuiModalHeader
css={css`
min-width: 700px;
`}
>
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize={'s'}>
<EuiFlexItem>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.insight.editModalTitle"
defaultMessage="Edit investigation query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.insight.addModalTitle"
defaultMessage="Add investigation query"
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
color={'hollow'}
label={i18n.translate('xpack.securitySolution.markdown.insight.technicalPreview', {
defaultMessage: 'Technical Preview',
})}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormProvider {...formMethods}>
<EuiForm fullWidth>
<EuiFormRow
label="Label"
helpText="Label for the filter button rendered in the guide"
fullWidth
>
<EuiFieldText
{...{
...formMethods.register('label'),
ref: null,
}}
name="label"
onChange={labelController.field.onChange}
/>
</EuiFormRow>
<EuiFormRow
label="Description"
helpText="Description of the relevance of the query"
fullWidth
>
<EuiFieldText
{...{ ...formMethods.register('description'), ref: null }}
name="description"
onChange={descriptionController.field.onChange}
/>
</EuiFormRow>
<EuiFormRow fullWidth>
<FiltersBuilderLazy
filters={filtersStub}
onChange={onChange}
dataView={kibanaDataProvider}
maxDepth={2}
/>
</EuiFormRow>
<EuiFormRow
label="Relative time range"
helpText="Select a time range relative to the time of the alert (optional)"
fullWidth
>
<EuiSelect
{...{ ...formMethods.register('relativeTimerange'), ref: null }}
onChange={selectOnChange}
options={dateRangeChoices}
/>
</EuiFormRow>
</EuiForm>
</FormProvider>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate('xpack.securitySolution.markdown.insight.modalCancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton onClick={formMethods.handleSubmit(onSubmit)} fill>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.insight.addModalConfirmButtonLabel"
defaultMessage="Add query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.insight.editModalConfirmButtonLabel"
defaultMessage="Save changes"
/>
)}
</EuiButton>
</EuiModalFooter>
</>
);
};
const InsightEditor = React.memo(InsightEditorComponent);
const exampleInsight = `!{insight{
"label": "Test action",
"description": "Click to investigate",
"providers": [
[
{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}
],
[
{"field": "event.action", "value": "", "queryType": "exists", "excluded": "false"},
{"field": "process.pid", "value": "{{process.pid}}", "queryType": "phrase", "excluded":"false"}
]
]
}}`;
export const plugin = {
name: 'insights',
button: {
label: 'Insights',
iconType: 'aggregate',
},
helpText: (
<div>
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
{exampleInsight}
</EuiCodeBlock>
<EuiSpacer size="s" />
</div>
),
editor: InsightEditor,
};

View file

@ -0,0 +1,47 @@
/*
* 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 { each } from 'lodash';
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
export const replaceParamsQuery = (
query: string | number | boolean,
data?: TimelineEventsDetailsItem[] | null
) => {
if (typeof query === 'number' || typeof query === 'boolean') {
return {
result: query,
skipped: true,
matchedBrackets: null,
};
}
const regex = /\{{([^}]+)\}}/g;
const matchedBrackets = query.match(regex);
let resultQuery = query;
if (matchedBrackets && data) {
each(matchedBrackets, (bracesText: string) => {
const field = bracesText.replace(/{{|}}/g, '').trim();
if (resultQuery.includes(bracesText)) {
const foundField = data.find(({ field: alertField }) => alertField === field);
if (foundField && foundField.values) {
const {
values: [foundFieldValue],
} = foundField;
resultQuery = resultQuery.replace(bracesText, foundFieldValue);
}
}
});
}
const skipped = regex.test(resultQuery);
return {
result: resultQuery,
skipped,
matchedBrackets,
};
};

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import type { DataProvider } from '@kbn/timelines-plugin/common';
import type { UseInsightDataProvidersProps, Provider } from './use_insight_data_providers';
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
import { useInsightDataProviders } from './use_insight_data_providers';
import {
useInsightDataProviders,
type UseInsightDataProvidersResult,
} from './use_insight_data_providers';
import { mockAlertDetailsData } from '../../../event_details/__mocks__';
const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => {
@ -18,115 +20,152 @@ const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => {
};
}) as TimelineEventsDetailsItem[];
const nestedAndProvider = [
const nestedAndProvider: Provider[][] = [
[
{
field: 'event.id',
value: 'kibana.alert.rule.uuid',
type: 'parameter',
value: '{{kibana.alert.rule.uuid}}',
queryType: 'phrase',
excluded: false,
},
],
[
{
field: 'event.category',
value: 'network',
type: 'literal',
queryType: 'phrase',
excluded: false,
},
{
field: 'process.pid',
value: 'process.pid',
type: 'parameter',
value: '{{process.pid}}',
queryType: 'phrase',
excluded: false,
},
],
] as Provider[][];
];
const topLevelOnly = [
[
{
field: 'event.id',
value: 'kibana.alert.rule.uuid',
type: 'parameter',
value: '{{kibana.alert.rule.uuid}}',
queryType: 'phrase',
excluded: false,
},
],
[
{
field: 'event.category',
value: 'network',
type: 'literal',
queryType: 'phrase',
excluded: false,
},
],
[
{
field: 'process.pid',
value: 'process.pid',
type: 'parameter',
value: 1000,
valueType: 'number',
queryType: 'phrase',
excluded: false,
},
],
] as Provider[][];
];
const nonExistantField = [
const nonExistantField: Provider[][] = [
[
{
field: 'event.id',
value: 'kibana.alert.rule.parameters.threshold.field',
type: 'parameter',
value: '{{kibana.alert.rule.parameters.threshold.field}}',
excluded: false,
queryType: 'phrase',
},
],
] as Provider[][];
];
const providerWithRange: Provider[][] = [
[
{
field: 'event.id',
value: '',
excluded: false,
queryType: 'exists',
},
{
field: 'event.id',
value: '{"gte":0,"lt":100}',
excluded: false,
queryType: 'range',
},
],
];
describe('useInsightDataProviders', () => {
it('should return 2 data providers, 1 with a nested provider ANDed to it', () => {
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
useInsightDataProviders({
providers: nestedAndProvider,
alertData: mockAlertDetailsDataWithIsObject,
})
);
const providers = result.current;
const { dataProviders: providers, filters } = result.current;
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
expect(providers.length).toBe(2);
expect(providersWithNonEmptyAnd.length).toBe(1);
expect(filters.length).toBe(0);
});
it('should return 3 data providers without any containing nested ANDs', () => {
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
useInsightDataProviders({
providers: topLevelOnly,
alertData: mockAlertDetailsDataWithIsObject,
})
);
const providers = result.current;
const { dataProviders: providers } = result.current;
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
expect(providers.length).toBe(3);
expect(providersWithNonEmptyAnd.length).toBe(0);
});
it('should use a wildcard for a field not present in an alert', () => {
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
it('should use the string literal if no field in the alert matches a bracketed value', () => {
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
useInsightDataProviders({
providers: nonExistantField,
alertData: mockAlertDetailsDataWithIsObject,
})
);
const providers = result.current;
const { dataProviders: providers } = result.current;
const {
queryMatch: { value },
} = providers[0];
expect(providers.length).toBe(1);
expect(value).toBe('*');
expect(value).toBe('{{kibana.alert.rule.parameters.threshold.field}}');
});
it('should use template data providers when called without alertData', () => {
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
useInsightDataProviders({
providers: nestedAndProvider,
})
);
const providers = result.current;
const { dataProviders: providers } = result.current;
const [first, second] = providers;
const [nestedSecond] = second.and;
expect(second.type).toBe('default');
expect(first.type).toBe('template');
expect(nestedSecond.type).toBe('template');
});
it('should return an empty array of dataProviders and populated filters if a provider contains a range type', () => {
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
useInsightDataProviders({
providers: providerWithRange,
})
);
const { dataProviders: providers, filters } = result.current;
expect(providers.length).toBe(0);
expect(filters.length > providers.length);
});
});

View file

@ -6,109 +6,241 @@
*/
import { useMemo } from 'react';
import type { Filter } from '@kbn/es-query';
import { FILTERS, BooleanRelation, FilterStateStore } from '@kbn/es-query';
import type { QueryOperator, DataProvider } from '@kbn/timelines-plugin/common';
import { DataProviderType } from '@kbn/timelines-plugin/common';
import { IS_OPERATOR } from '../../../../../timelines/components/timeline/data_providers/data_provider';
import { replaceParamsQuery } from './replace_params_query';
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
import {
EXISTS_OPERATOR,
IS_OPERATOR,
IS_ONE_OF_OPERATOR,
} from '../../../../../timelines/components/timeline/data_providers/data_provider';
export interface Provider {
field: string;
value: string;
type: 'parameter' | 'value';
excluded: boolean;
queryType: string;
value: string | number | boolean;
valueType?: string;
}
export interface UseInsightDataProvidersProps {
providers: Provider[][];
alertData?: TimelineEventsDetailsItem[] | null;
}
export interface UseInsightDataProvidersResult {
dataProviders: DataProvider[];
filters: Filter[];
}
const dataProviderQueryType = (type: string): QueryOperator => {
if (type === FILTERS.EXISTS) {
return EXISTS_OPERATOR;
} else if (type === FILTERS.PHRASES) {
return IS_ONE_OF_OPERATOR;
} else {
return IS_OPERATOR;
}
};
const buildDataProviders = (
providers: Provider[][],
alertData?: TimelineEventsDetailsItem[] | null
): DataProvider[] => {
return providers.map((innerProvider) => {
return innerProvider.reduce((prev, next, index): DataProvider => {
const { field, value, excluded, queryType } = next;
const { result, matchedBrackets } = replaceParamsQuery(value, alertData);
const isTemplate = !alertData && matchedBrackets;
if (index === 0) {
return {
and: [],
enabled: true,
id: JSON.stringify(field + value),
name: field,
excluded,
kqlQuery: '',
type: isTemplate ? DataProviderType.template : DataProviderType.default,
queryMatch: {
field,
value: result,
operator: dataProviderQueryType(queryType),
},
};
} else {
const newProvider = {
and: [],
enabled: true,
id: JSON.stringify(field + value),
name: field,
excluded,
kqlQuery: '',
type: isTemplate ? DataProviderType.template : DataProviderType.default,
queryMatch: {
field,
value: result,
operator: dataProviderQueryType(queryType),
},
};
prev.and.push(newProvider);
}
return prev;
}, {} as DataProvider);
});
};
const filterStub = {
$state: {
store: FilterStateStore.APP_STATE,
},
meta: {
disabled: false,
negate: false,
alias: null,
index: undefined,
},
};
const buildPrimitiveFilter = (provider: Provider): Filter => {
const baseFilter = {
...filterStub,
meta: {
...filterStub.meta,
negate: provider.excluded,
type: provider.queryType,
},
};
if (provider.queryType === FILTERS.EXISTS) {
return {
...baseFilter,
meta: {
...baseFilter.meta,
params: undefined,
value: 'exists',
},
query: { exists: { field: provider.field } },
};
} else if (provider.queryType === FILTERS.PHRASES) {
let values = JSON.parse(String(provider.value));
if (provider.valueType === 'number') {
values = values.map(Number);
} else if (provider.valueType === 'boolean') {
values = values.map(Boolean);
}
return {
...baseFilter,
meta: {
...baseFilter.meta,
},
query: {
bool: {
minimum_should_match: 1,
should: values?.map((param: string | number | boolean) => ({
match_phrase: { [provider.field]: param },
})),
},
},
};
} else if (provider.queryType === FILTERS.PHRASE) {
return {
...baseFilter,
meta: {
...baseFilter.meta,
params: { query: provider.value },
value: undefined,
},
query: { match_phrase: { [provider.field]: provider.value ?? '' } },
};
} else if (provider.queryType === FILTERS.RANGE) {
let gte;
let lt;
try {
const input = JSON.parse(String(provider.value));
gte = input.gte;
lt = input.lt;
} catch {
gte = '';
lt = '';
}
const params = {
gte,
lt,
};
return {
...baseFilter,
meta: {
...baseFilter.meta,
params,
},
query: {
range: {
[provider.field]: params,
},
},
};
} else {
return baseFilter;
}
};
const buildFiltersFromInsightProviders = (
providers: Provider[][],
alertData?: TimelineEventsDetailsItem[] | null
): Filter[] => {
const filters: Filter[] = [];
for (let index = 0; index < providers.length; index++) {
const provider = providers[index];
if (provider.length > 1) {
// Only support 1 level of nesting currently
const innerProviders = provider.map((innerProvider) => {
return buildPrimitiveFilter(innerProvider);
});
const combinedFilter = {
$state: {
store: FilterStateStore.APP_STATE,
},
meta: {
type: FILTERS.COMBINED,
relation: BooleanRelation.AND,
params: innerProviders,
index: undefined,
disabled: false,
negate: false,
},
};
filters.push(combinedFilter);
} else {
const baseProvider = provider[0];
const baseFilter = buildPrimitiveFilter(baseProvider);
filters.push(baseFilter);
}
}
return filters;
};
export const useInsightDataProviders = ({
providers,
alertData,
}: UseInsightDataProvidersProps): DataProvider[] => {
function getFieldValue(fields: TimelineEventsDetailsItem[], fieldToFind: string) {
const alertField = fields.find((dataField) => dataField.field === fieldToFind);
return alertField?.values ? alertField.values[0] : '*';
}
}: UseInsightDataProvidersProps): UseInsightDataProvidersResult => {
const providersContainRangeQuery = useMemo(() => {
return providers.some((innerProvider) => {
return innerProvider.some((provider) => provider.queryType === 'range');
});
}, [providers]);
const dataProviders: DataProvider[] = useMemo(() => {
if (alertData) {
return providers.map((innerProvider) => {
return innerProvider.reduce((prev, next, index): DataProvider => {
const { field, value, type } = next;
if (index === 0) {
return {
and: [],
enabled: true,
id: JSON.stringify(field + value + type),
name: field,
excluded: false,
kqlQuery: '',
type: DataProviderType.default,
queryMatch: {
field,
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
operator: IS_OPERATOR as QueryOperator,
},
};
} else {
const newProvider = {
and: [],
enabled: true,
id: JSON.stringify(field + value + type),
name: field,
excluded: false,
kqlQuery: '',
type: DataProviderType.default,
queryMatch: {
field,
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
operator: IS_OPERATOR as QueryOperator,
},
};
prev.and.push(newProvider);
}
return prev;
}, {} as DataProvider);
});
if (providersContainRangeQuery) {
return [];
} else {
return providers.map((innerProvider) => {
return innerProvider.reduce((prev, next, index) => {
const { field, value, type } = next;
if (index === 0) {
return {
and: [],
enabled: true,
id: JSON.stringify(field + value + type),
name: field,
excluded: false,
kqlQuery: '',
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
queryMatch: {
field,
value: type === 'parameter' ? `{${value}}` : value,
operator: IS_OPERATOR as QueryOperator,
},
};
} else {
const newProvider = {
and: [],
enabled: true,
id: JSON.stringify(field + value + type),
name: field,
excluded: false,
kqlQuery: '',
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
queryMatch: {
field,
value: type === 'parameter' ? `{${value}}` : value,
operator: IS_OPERATOR as QueryOperator,
},
};
prev.and.push(newProvider);
}
return prev;
}, {} as DataProvider);
});
return buildDataProviders(providers, alertData);
}
}, [alertData, providers]);
return dataProviders;
}, [alertData, providers, providersContainRangeQuery]);
const filters = useMemo(() => {
if (!providersContainRangeQuery) {
return [];
} else {
return buildFiltersFromInsightProviders(providers, alertData);
}
}, [providersContainRangeQuery, providers, alertData]);
return { dataProviders, filters };
};

View file

@ -33,6 +33,7 @@ describe('useInsightQuery', () => {
() =>
useInsightQuery({
dataProviders: [mockProvider],
filters: [],
}),
{
wrapper: TestProviders,

View file

@ -6,6 +6,7 @@
*/
import { useMemo, useState } from 'react';
import type { Filter } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DataProvider } from '@kbn/timelines-plugin/common';
import { TimelineId } from '../../../../../../common/types/timeline';
@ -17,6 +18,7 @@ import { SourcererScopeName } from '../../../../store/sourcerer/model';
export interface UseInsightQuery {
dataProviders: DataProvider[];
filters: Filter[];
}
export interface UseInsightQueryResult {
@ -26,7 +28,10 @@ export interface UseInsightQueryResult {
hasError: boolean;
}
export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQueryResult => {
export const useInsightQuery = ({
dataProviders,
filters,
}: UseInsightQuery): UseInsightQueryResult => {
const { uiSettings } = useKibana().services;
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
const { browserFields, selectedPatterns, indexPattern, dataViewId } = useSourcererDataView(
@ -41,7 +46,7 @@ export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQ
dataProviders,
indexPattern,
browserFields,
filters: [],
filters,
kqlQuery: {
query: '',
language: 'kuery',
@ -54,7 +59,7 @@ export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQ
setHasError(true);
return null;
}
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern]);
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern, filters]);
const [isQueryLoading, { events, totalCount }] = useTimelineEvents({
dataViewId,

View file

@ -446,6 +446,7 @@ export const useSourcererDataView = (
selectedPatterns,
// if we have to do an update to data view, tell us which patterns are active
...(legacyPatterns.length > 0 ? { activePatterns: sourcererDataView.patternList } : {}),
sourcererDataView,
}),
[sourcererDataView, selectedPatterns, indicesExist, loading, legacyPatterns.length]
);

View file

@ -18,6 +18,12 @@ import type { BrowserFields } from '../../../../common/search_strategy';
import type { DataProvider, DataProvidersAnd } from '../../../../common/types';
import { DataProviderType, EXISTS_OPERATOR } from '../../../../common/types';
export type PrimitiveOrArrayOfPrimitives =
| string
| number
| boolean
| Array<string | number | boolean>;
export interface CombineQueries {
config: EsQueryConfig;
dataProviders: DataProvider[];
@ -29,8 +35,8 @@ export interface CombineQueries {
}
export const escapeQueryValue = (
val: string | number | Array<string | number> = ''
): string | number | Array<string | number> => {
val: PrimitiveOrArrayOfPrimitives = ''
): PrimitiveOrArrayOfPrimitives => {
if (isString(val)) {
if (isEmpty(val)) {
return '""';
@ -68,12 +74,13 @@ export const convertKueryToElasticSearchQuery = (
}
};
const isNumber = (value: string | number | Array<string | number>) => !isNaN(Number(value));
export const isNumber = (value: PrimitiveOrArrayOfPrimitives): value is number =>
!isNaN(Number(value));
const convertDateFieldToQuery = (field: string, value: string | number | Array<string | number>) =>
export const convertDateFieldToQuery = (field: string, value: PrimitiveOrArrayOfPrimitives) =>
`${field}: ${isNumber(value) ? value : new Date(value.toString()).valueOf()}`;
const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
export const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
const baseFields = get('base', browserFields);
if (baseFields != null && baseFields.fields != null) {
return Object.keys(baseFields.fields);
@ -81,7 +88,7 @@ const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
return [];
});
const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
export const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
const splitFields = field.split('.');
const baseFields = getBaseFields(browserFields);
if (baseFields.includes(field)) {
@ -90,7 +97,7 @@ const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
return [splitFields[0], 'fields', field];
};
const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
export const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.type === 'date') {
@ -99,9 +106,9 @@ const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) =>
return false;
};
const convertNestedFieldToQuery = (
export const convertNestedFieldToQuery = (
field: string,
value: string | number | Array<string | number>,
value: PrimitiveOrArrayOfPrimitives,
browserFields: BrowserFields
) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
@ -111,7 +118,7 @@ const convertNestedFieldToQuery = (
return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`;
};
const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
export const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
const nestedPath = browserField.subType.nested.path;
@ -119,7 +126,7 @@ const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFie
return `${nestedPath}: { ${key}: * }`;
};
const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
export const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.subType && browserField.subType.nested) {

View file

@ -93,6 +93,7 @@ export interface SelectedDataView {
selectedPatterns: SourcererScope['selectedPatterns'];
// active patterns when dataViewId == null
activePatterns?: string[];
sourcererDataView?: SourcererDataView | (Omit<SourcererDataView, 'id'> & { id: string | null });
}
/**

View file

@ -10,12 +10,13 @@ import React, { useState, useEffect, useCallback } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { isStringOrNumberArray } from '../../timeline/helpers';
import { isPrimitiveArray } from '../../timeline/helpers';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import * as i18n from '../translations';
interface ControlledDataProviderInput {
onChangeCallback: (value: string | number | string[]) => void;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
}
export const ControlledComboboxInput = ({
@ -71,9 +72,9 @@ export const ControlledComboboxInput = ({
};
export const convertValuesToComboboxValueArray = (
values: string | number | Array<string | number>
values: PrimitiveOrArrayOfPrimitives
): EuiComboBoxOptionOption[] =>
isStringOrNumberArray(values) ? values.map((item) => ({ label: String(item) })) : [];
isPrimitiveArray(values) ? values.map((item) => ({ label: String(item) })) : [];
export const convertComboboxValuesToStringArray = (values: EuiComboBoxOptionOption[]): string[] =>
values.map((item) => item.label);

View file

@ -9,13 +9,14 @@ import React, { useState, useEffect, useCallback } from 'react';
import { EuiFieldText } from '@elastic/eui';
import { isStringOrNumberArray } from '../../timeline/helpers';
import { isPrimitiveArray } from '../../timeline/helpers';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import { sanatizeValue } from '../helpers';
import * as i18n from '../translations';
interface ControlledDataProviderInput {
onChangeCallback: (value: string | number | string[]) => void;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
}
const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value';
@ -24,7 +25,9 @@ export const ControlledDefaultInput = ({
value,
onChangeCallback,
}: ControlledDataProviderInput) => {
const [primitiveValue, setPrimitiveValue] = useState<string | number>(getDefaultValue(value));
const [primitiveValue, setPrimitiveValue] = useState<string | number | boolean>(
getDefaultValue(value)
);
useEffect(() => {
onChangeCallback(sanatizeValue(primitiveValue));
@ -44,10 +47,8 @@ export const ControlledDefaultInput = ({
);
};
export const getDefaultValue = (
value: string | number | Array<string | number>
): string | number => {
if (isStringOrNumberArray(value)) {
export const getDefaultValue = (value: PrimitiveOrArrayOfPrimitives): string | number | boolean => {
if (isPrimitiveArray(value)) {
return value[0] ?? '';
} else return value;
};

View file

@ -126,9 +126,8 @@ export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionOpti
};
/** Ensure that a value passed to ControlledDefaultInput is not an array */
export const sanatizeValue = (value: string | number | unknown[]): string => {
export const sanatizeValue = (value: string | number | boolean | unknown[]): string => {
if (Array.isArray(value)) {
// fun fact: value should never be an array
return value.length ? `${value[0]}` : '';
}
return `${value}`;

View file

@ -21,6 +21,7 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react';
import styled from 'styled-components';
import type { BrowserFields } from '../../../common/containers/source';
import type { PrimitiveOrArrayOfPrimitives } from '../../../common/lib/kuery';
import type { OnDataProviderEdited } from '../timeline/events';
import type { QueryOperator } from '../timeline/data_providers/data_provider';
import { DataProviderType } from '../timeline/data_providers/data_provider';
@ -57,7 +58,7 @@ interface Props {
operator: QueryOperator;
providerId: string;
timelineId: string;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
type?: DataProviderType;
}
@ -92,9 +93,7 @@ export const StatefulEditDataProvider = React.memo<Props>(
getInitialOperatorLabel(isExcluded, operator)
);
const [updatedValue, setUpdatedValue] = useState<string | number | Array<string | number>>(
value
);
const [updatedValue, setUpdatedValue] = useState<PrimitiveOrArrayOfPrimitives>(value);
const showComboBoxInput = useMemo(
() =>

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
/** Represents the Timeline data providers */
/** The `is` operator in a KQL query */
@ -27,8 +27,8 @@ export enum DataProviderType {
export interface QueryMatch {
field: string;
displayField?: string;
value: string | number | Array<string | number>;
displayValue?: string | number;
value: PrimitiveOrArrayOfPrimitives;
displayValue?: string | number | boolean;
operator: QueryOperator;
}

View file

@ -10,7 +10,8 @@ import type { DraggableLocation } from 'react-beautiful-dnd';
import type { Dispatch } from 'redux';
import { updateProviders } from '../../../store/timeline/actions';
import { isStringOrNumberArray } from '../helpers';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import { isPrimitiveArray } from '../helpers';
import type { DataProvider, DataProvidersAnd } from './data_provider';
@ -344,10 +345,8 @@ export const addContentToTimeline = ({
}
};
export const getDisplayValue = (
value: string | number | Array<string | number>
): string | number => {
if (isStringOrNumberArray(value)) {
export const getDisplayValue = (value: PrimitiveOrArrayOfPrimitives): string | number | boolean => {
if (isPrimitiveArray(value)) {
if (value.length) {
return `( ${value.join(' OR ')} )`;
}

View file

@ -11,6 +11,7 @@ import { isString } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import { TimelineType } from '../../../../../common/types/timeline';
import { getEmptyString } from '../../../../common/components/empty_value';
import { ProviderContainer } from '../../../../common/components/drag_and_drop/provider_container';
@ -103,7 +104,7 @@ interface ProviderBadgeProps {
togglePopover: () => void;
toggleType: () => void;
displayValue: string;
val: string | number | Array<string | number>;
val: PrimitiveOrArrayOfPrimitives;
operator: QueryOperator;
type: DataProviderType;
timelineType: TimelineType;

View file

@ -12,6 +12,7 @@ import React from 'react';
import styled from 'styled-components';
import { TimelineType } from '../../../../../common/types/timeline';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import type { BrowserFields } from '../../../../common/containers/source';
import type { OnDataProviderEdited } from '../events';
@ -48,7 +49,7 @@ interface OwnProps {
toggleEnabledProvider: () => void;
toggleExcludedProvider: () => void;
toggleTypeProvider: () => void;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
type: DataProviderType;
}
@ -80,7 +81,7 @@ interface GetProviderActionsProps {
toggleEnabled: () => void;
toggleExcluded: () => void;
toggleType: () => void;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
type: DataProviderType;
}

View file

@ -16,6 +16,7 @@ import {
useShallowEqualSelector,
} from '../../../../common/hooks/use_selector';
import { timelineSelectors } from '../../../store/timeline';
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
import type { OnDataProviderEdited } from '../events';
import { ProviderBadge } from './provider_badge';
@ -44,7 +45,7 @@ interface ProviderItemBadgeProps {
toggleExcludedProvider: () => void;
toggleTypeProvider: () => void;
displayValue?: string;
val: string | number | Array<string | number>;
val: PrimitiveOrArrayOfPrimitives;
type?: DataProviderType;
wrapperRef?: React.MutableRefObject<HTMLDivElement | null>;
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { PrimitiveOrArrayOfPrimitives } from '../../../common/lib/kuery';
import type { ColumnId } from './body/column_id';
import type { DataProvider, QueryOperator } from './data_providers/data_provider';
export type {
@ -36,7 +37,7 @@ export type OnDataProviderEdited = ({
id: string;
operator: QueryOperator;
providerId: string;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
type: DataProvider['type'];
}) => void;

View file

@ -16,7 +16,7 @@ import {
buildIsOneOfQueryMatch,
buildIsQueryMatch,
handleIsOperator,
isStringOrNumberArray,
isPrimitiveArray,
showGlobalFilters,
} from './helpers';
@ -274,27 +274,27 @@ describe('Build KQL Query', () => {
describe('isStringOrNumberArray', () => {
test('it returns false when value is not an array', () => {
expect(isStringOrNumberArray('just a string')).toBe(false);
expect(isPrimitiveArray('just a string')).toBe(false);
});
test('it returns false when value is an array of mixed types', () => {
expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false);
expect(isPrimitiveArray(['mixed', 123, 'types'])).toBe(false);
});
test('it returns false when value is an array of bad types', () => {
const badValues = [undefined, null, {}] as unknown as string[];
expect(isStringOrNumberArray(badValues)).toBe(false);
expect(isPrimitiveArray(badValues)).toBe(false);
});
test('it returns true when value is an empty array', () => {
expect(isStringOrNumberArray([])).toBe(true);
expect(isPrimitiveArray([])).toBe(true);
});
test('it returns true when value is an array of all strings', () => {
expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true);
expect(isPrimitiveArray(['all', 'string', 'values'])).toBe(true);
});
test('it returns true when value is an array of all numbers', () => {
expect(isStringOrNumberArray([123, 456, 789])).toBe(true);
expect(isPrimitiveArray([123, 456, 789])).toBe(true);
});
describe('queryHandlerFunctions', () => {

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import { isEmpty, get } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import { isEmpty } from 'lodash/fp';
import {
elementOrChildrenHasFocus,
@ -18,7 +17,16 @@ import {
import { assertUnreachable } from '../../../../common/utility_types';
import type { BrowserFields } from '../../../common/containers/source';
import { escapeQueryValue } from '../../../common/lib/kuery';
import {
escapeQueryValue,
isNumber,
convertDateFieldToQuery,
checkIfFieldTypeIsDate,
convertNestedFieldToQuery,
convertNestedFieldToExistQuery,
checkIfFieldTypeIsNested,
type PrimitiveOrArrayOfPrimitives,
} from '../../../common/lib/kuery';
import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider';
import {
DataProviderType,
@ -28,66 +36,6 @@ import {
} from './data_providers/data_provider';
import { EVENTS_TABLE_CLASS_NAME } from './styles';
const isNumber = (value: string | number): value is number => !isNaN(Number(value));
const convertDateFieldToQuery = (field: string, value: string | number) =>
`${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`;
const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
const baseFields = get('base', browserFields);
if (baseFields != null && baseFields.fields != null) {
return Object.keys(baseFields.fields);
}
return [];
});
const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
const splitFields = field.split('.');
const baseFields = getBaseFields(browserFields);
if (baseFields.includes(field)) {
return ['base', 'fields', field];
}
return [splitFields[0], 'fields', field];
};
const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.type === 'date') {
return true;
}
return false;
};
const convertNestedFieldToQuery = (
field: string,
value: string | number,
browserFields: BrowserFields
) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
const nestedPath = browserField.subType.nested.path;
const key = field.replace(`${nestedPath}.`, '');
return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`;
};
const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
const nestedPath = browserField.subType.nested.path;
const key = field.replace(`${nestedPath}.`, '');
return `${nestedPath}: { ${key}: * }`;
};
const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.subType && browserField.subType.nested) {
return true;
}
return false;
};
const buildQueryMatch = (
dataProvider: DataProvider | DataProvidersAnd,
browserFields: BrowserFields
@ -265,7 +213,7 @@ export const resetKeyboardFocus = () => {
interface OperatorHandler {
field: string;
isExcluded: string;
value: string | number | Array<string | number>;
value: PrimitiveOrArrayOfPrimitives;
}
export const handleIsOperator = ({
@ -280,7 +228,7 @@ export const handleIsOperator = ({
isFieldTypeNested: boolean;
type?: DataProviderType;
}) => {
if (!isStringOrNumberArray(value)) {
if (!isPrimitiveArray(value)) {
return `${isExcluded}${
type !== DataProviderType.template
? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value })
@ -292,7 +240,7 @@ export const handleIsOperator = ({
};
const handleIsOneOfOperator = ({ field, isExcluded, value }: OperatorHandler) => {
if (isStringOrNumberArray(value)) {
if (isPrimitiveArray(value)) {
return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`;
} else {
return `${isExcluded}${field} : ${JSON.stringify(value)}`;
@ -308,7 +256,7 @@ export const buildIsQueryMatch = ({
browserFields: BrowserFields;
field: string;
isFieldTypeNested: boolean;
value: string | number;
value: string | number | boolean;
}): string => {
if (isFieldTypeNested) {
return convertNestedFieldToQuery(field, value, browserFields);
@ -338,17 +286,17 @@ export const buildIsOneOfQueryMatch = ({
value,
}: {
field: string;
value: Array<string | number>;
value: Array<string | number | boolean>;
}): string => {
const trimmedField = field.trim();
if (value.length) {
return `${trimmedField} : (${value
.map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(item.trim())}`))
.map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(String(item).trim())}`))
.join(' OR ')})`;
}
return `${trimmedField} : ''`;
};
export const isStringOrNumberArray = (value: unknown): value is Array<string | number> =>
export const isPrimitiveArray = (value: unknown): value is Array<string | number | boolean> =>
Array.isArray(value) &&
(value.every((x) => typeof x === 'string') || value.every((x) => typeof x === 'number'));

View file

@ -27,8 +27,8 @@ export enum DataProviderType {
export interface QueryMatch {
field: string;
displayField?: string;
value: string | number | Array<string | number>;
displayValue?: string | number;
value: string | number | boolean | Array<string | number | boolean>;
displayValue?: string | number | boolean;
operator: QueryOperator;
}