[Security Solution][Detections] - rule query preview bug fix (#80750) (#81196)

### Summary 

This PR addresses the remaining query preview bugs. 

- it adds index, and request information to eql inspect - it seems that for some reason the eql search strategy response returns `null` for the `params.body` in complete responses, but not in partial responses and does not include index info. As a workaround, I set the inspect info on partial responses and manually add index info
  - added to-dos pointing this out in the code
- updated eql sequence queries preview to use the last event timestamp of a sequence to display the hits within a histogram
- it checks buckets length to determine noise warning for threshold rules, as opposed to total hit count
- remove unused i18n text
- fixes bug where threshold is being passed in for all rule types as it's always defined in the creation step, added a check to only pass through to `useMatrixHistogram` hook when rule type is threshold
This commit is contained in:
Yara Tercero 2020-10-20 13:56:07 -04:00 committed by GitHub
parent b034db9ed7
commit a92a3d1af7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1186 additions and 590 deletions

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common';
import { Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types';
import { Connection } from '@elastic/elasticsearch';
export const getMockEqlResponse = (): EqlSearchStrategyResponse<EqlSearchResponse<Source>> => ({
id: 'some-id',
rawResponse: {
body: {
hits: {
events: [
{
_index: 'index',
_id: '1',
_source: {
'@timestamp': '2020-10-04T15:16:54.368707900Z',
},
},
{
_index: 'index',
_id: '2',
_source: {
'@timestamp': '2020-10-04T15:50:54.368707900Z',
},
},
{
_index: 'index',
_id: '3',
_source: {
'@timestamp': '2020-10-04T15:06:54.368707900Z',
},
},
{
_index: 'index',
_id: '4',
_source: {
'@timestamp': '2020-10-04T15:15:54.368707900Z',
},
},
],
total: {
value: 4,
relation: '',
},
},
is_partial: false,
is_running: false,
took: 300,
timed_out: false,
},
headers: {},
warnings: [],
meta: {
aborted: false,
attempts: 0,
context: null,
name: 'elasticsearch-js',
connection: {} as Connection,
request: {
params: {
body: JSON.stringify({
filter: {
range: {
'@timestamp': {
gte: '2020-10-07T00:46:12.414Z',
lte: '2020-10-07T01:46:12.414Z',
format: 'strict_date_optional_time',
},
},
},
}),
method: 'GET',
path: '/_eql/search/',
querystring: 'some query string',
},
options: {},
id: '',
},
},
statusCode: 200,
},
});
export const getMockEqlSequenceResponse = (): EqlSearchStrategyResponse<
EqlSearchResponse<Source>
> => ({
id: 'some-id',
rawResponse: {
body: {
hits: {
sequences: [
{
join_keys: [],
events: [
{
_index: 'index',
_id: '1',
_source: {
'@timestamp': '2020-10-04T15:16:54.368707900Z',
},
},
{
_index: 'index',
_id: '2',
_source: {
'@timestamp': '2020-10-04T15:50:54.368707900Z',
},
},
],
},
{
join_keys: [],
events: [
{
_index: 'index',
_id: '3',
_source: {
'@timestamp': '2020-10-04T15:06:54.368707900Z',
},
},
{
_index: 'index',
_id: '4',
_source: {
'@timestamp': '2020-10-04T15:15:54.368707900Z',
},
},
],
},
],
total: {
value: 4,
relation: '',
},
},
is_partial: false,
is_running: false,
took: 300,
timed_out: false,
},
headers: {},
warnings: [],
meta: {
aborted: false,
attempts: 0,
context: null,
name: 'elasticsearch-js',
connection: {} as Connection,
request: {
params: {
body: JSON.stringify({
filter: {
range: {
'@timestamp': {
gte: '2020-10-07T00:46:12.414Z',
lte: '2020-10-07T01:46:12.414Z',
format: 'strict_date_optional_time',
},
},
},
}),
method: 'GET',
path: '/_eql/search/',
querystring: 'some query string',
},
options: {},
id: '',
},
},
statusCode: 200,
},
});

View file

@ -10,55 +10,105 @@ import { inputsModel } from '../../../common/store';
import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common'; import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common';
import { InspectResponse } from '../../../types'; import { InspectResponse } from '../../../types';
import { EqlPreviewResponse, Source } from './types'; import { EqlPreviewResponse, Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types'; import { BaseHit, EqlSearchResponse } from '../../../../common/detection_engine/types';
type EqlAggBuckets = Record<string, { timestamp: string; total: number }>; type EqlAggBuckets = Record<string, { timestamp: string; total: number }>;
export const EQL_QUERY_EVENT_SIZE = 100; export const EQL_QUERY_EVENT_SIZE = 100;
// Calculates which 2 min bucket segment, event should be /**
// sorted into * Calculates which 2 min bucket segment, event should be sorted into
* @param eventTimestamp The event to be bucketed timestamp
* @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred
*/
export const calculateBucketForHour = (eventTimestamp: number, relativeNow: number): number => { export const calculateBucketForHour = (eventTimestamp: number, relativeNow: number): number => {
const diff: number = relativeNow - eventTimestamp; const diff = Math.abs(relativeNow - eventTimestamp);
const minutes: number = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
return Math.ceil(minutes / 2) * 2; return Math.ceil(minutes / 2) * 2;
}; };
// Calculates which 1 hour bucket segment, event should be /**
// sorted into * Calculates which 1 hour bucket segment, event should be sorted into
* @param eventTimestamp The event to be bucketed timestamp
* @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred
*/
export const calculateBucketForDay = (eventTimestamp: number, relativeNow: number): number => { export const calculateBucketForDay = (eventTimestamp: number, relativeNow: number): number => {
const diff: number = relativeNow - eventTimestamp; const diff = Math.abs(relativeNow - eventTimestamp);
const minutes: number = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
return Math.ceil(minutes / 60); return Math.ceil(minutes / 60);
}; };
/**
* Formats the response for the UI inspect modal
* @param response The query search response
* @param indices The indices the query searched
* TODO: Update eql search strategy to return index in it's meta
* params info, currently not being returned, but expected for
* inspect modal display
*/
export const formatInspect = ( export const formatInspect = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>> response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
indices: string[]
): InspectResponse => { ): InspectResponse => {
const body = response.rawResponse.meta.request.params.body; const body = response.rawResponse.meta.request.params.body;
const bodyParse = typeof body === 'string' ? JSON.parse(body) : body; const bodyParse: Record<string, unknown> | undefined =
typeof body === 'string' ? JSON.parse(body) : body;
return { return {
dsl: [ dsl: [
JSON.stringify({ ...response.rawResponse.meta.request.params, body: bodyParse }, null, 2), JSON.stringify(
{ ...response.rawResponse.meta.request.params, index: indices, body: bodyParse },
null,
2
),
], ],
response: [JSON.stringify(response.rawResponse.body, null, 2)], response: [JSON.stringify(response.rawResponse.body, null, 2)],
}; };
}; };
// NOTE: Eql does not support aggregations, this is an in-memory /**
// hand-spun aggregation for the events to give the user a visual * Gets the events out of the response based on type of query
// representation of their query results * @param isSequence Is the eql query a sequence query
* @param response The query search response
*/
export const getEventsToBucket = (
isSequence: boolean,
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>
): Array<BaseHit<Source>> => {
const hits = response.rawResponse.body.hits ?? [];
if (isSequence) {
return (
hits.sequences?.map((seq) => {
return seq.events[seq.events.length - 1];
}) ?? []
);
} else {
return hits.events ?? [];
}
};
/**
* Eql does not support aggregations, this is an in-memory
* hand-spun aggregation for the events to give the user a visual
* representation of their query results
* @param response The query search response
* @param range User chosen timeframe (last hour, day)
* @param to Based on range chosen
* @param refetch Callback used in inspect button, ref just passed through
* @param indices Indices searched by query
* @param isSequence Is the eql query a sequence query
*/
export const getEqlAggsData = ( export const getEqlAggsData = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>, response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
range: Unit, range: Unit,
to: string, to: string,
refetch: inputsModel.Refetch refetch: inputsModel.Refetch,
indices: string[],
isSequence: boolean
): EqlPreviewResponse => { ): EqlPreviewResponse => {
const { dsl, response: inspectResponse } = formatInspect(response); const { dsl, response: inspectResponse } = formatInspect(response, indices);
// The upper bound of the timestamps
const relativeNow = Date.parse(to); const relativeNow = Date.parse(to);
const accumulator = getInterval(range, relativeNow); const accumulator = getInterval(range, relativeNow);
const events = response.rawResponse.body.hits.events ?? []; const events = getEventsToBucket(isSequence, response);
const totalCount = response.rawResponse.body.hits.total.value; const totalCount = response.rawResponse.body.hits.total.value;
const buckets = events.reduce<EqlAggBuckets>((acc, hit) => { const buckets = events.reduce<EqlAggBuckets>((acc, hit) => {
@ -94,12 +144,23 @@ export const getEqlAggsData = (
}; };
}; };
export const createIntervalArray = (start: number, end: number, multiplier: number) => { /**
* Helper method to create an array to be used for calculating bucket intervals
* @param start
* @param end
* @param multiplier
*/
export const createIntervalArray = (start: number, end: number, multiplier: number): number[] => {
return Array(end - start + 1) return Array(end - start + 1)
.fill(0) .fill(0)
.map((_, idx) => start + idx * multiplier); .map((_, idx) => start + idx * multiplier);
}; };
/**
* Helper method to create an array to be used for calculating bucket intervals
* @param range User chosen timeframe (last hour, day)
* @param relativeNow Based on range chosen
*/
export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => { export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => {
switch (range) { switch (range) {
case 'h': case 'h':
@ -117,38 +178,6 @@ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets =>
}; };
}, {}); }, {});
default: default:
throw new Error('Invalid time range selected'); throw new RangeError('Invalid time range selected. Must be "Last hour" or "Last day".');
} }
}; };
export const getSequenceAggs = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
refetch: inputsModel.Refetch
): EqlPreviewResponse => {
const { dsl, response: inspectResponse } = formatInspect(response);
const sequences = response.rawResponse.body.hits.sequences ?? [];
const totalCount = response.rawResponse.body.hits.total.value;
const data = sequences.map((sequence, i) => {
return sequence.events.map((seqEvent) => {
if (seqEvent._source['@timestamp'] == null) {
return {};
}
return {
x: seqEvent._source['@timestamp'],
y: 1,
g: `Seq. ${i + 1}`,
};
});
});
return {
data: data.flat(),
totalCount,
inspect: {
dsl,
response: inspectResponse,
},
refetch,
};
};

View file

@ -14,7 +14,7 @@ import { Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types'; import { EqlSearchResponse } from '../../../../common/detection_engine/types';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { useEqlPreview } from '.'; import { useEqlPreview } from '.';
import { getMockResponse } from './helpers.test'; import { getMockEqlResponse } from './eql_search_response.mock';
jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/lib/kibana');
@ -32,7 +32,9 @@ describe('useEqlPreview', () => {
useKibana().services.notifications.toasts.addWarning = jest.fn(); useKibana().services.notifications.toasts.addWarning = jest.fn();
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); (useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
);
}); });
it('should initiate hook', async () => { it('should initiate hook', async () => {
@ -96,7 +98,7 @@ describe('useEqlPreview', () => {
it('should not resolve values after search is invoked if component unmounted', async () => { it('should not resolve values after search is invoked if component unmounted', async () => {
await act(async () => { await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue( (useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockResponse()).pipe(delay(5000)) of(getMockEqlResponse()).pipe(delay(5000))
); );
const { result, waitForNextUpdate, unmount } = renderHook(() => useEqlPreview()); const { result, waitForNextUpdate, unmount } = renderHook(() => useEqlPreview());
@ -117,9 +119,11 @@ describe('useEqlPreview', () => {
it('should not resolve new values on search if response is error response', async () => { it('should not resolve new values on search if response is error response', async () => {
await act(async () => { await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue( (useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of({ isRunning: false, isPartial: true } as EqlSearchStrategyResponse< of<EqlSearchStrategyResponse<EqlSearchResponse<Source>>>({
EqlSearchResponse<Source> ...getMockEqlResponse(),
>) isRunning: false,
isPartial: true,
})
); );
const { result, waitForNextUpdate } = renderHook(() => useEqlPreview()); const { result, waitForNextUpdate } = renderHook(() => useEqlPreview());
@ -136,6 +140,30 @@ describe('useEqlPreview', () => {
}); });
}); });
// TODO: Determine why eql search strategy returns null for meta.params.body
// in complete responses, but not in partial responses
it('should update inspect information on partial response', async () => {
const mockResponse = getMockEqlResponse();
await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of<EqlSearchStrategyResponse<EqlSearchResponse<Source>>>({
isRunning: true,
isPartial: true,
rawResponse: mockResponse.rawResponse,
})
);
const { result, waitForNextUpdate } = renderHook(() => useEqlPreview());
await waitForNextUpdate();
result.current[1](params);
expect(result.current[2].inspect.dsl.length).toEqual(1);
expect(result.current[2].inspect.response.length).toEqual(1);
});
});
it('should add danger toast if search throws', async () => { it('should add danger toast if search throws', async () => {
await act(async () => { await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue( (useKibana().services.data.search.search as jest.Mock).mockReturnValue(

View file

@ -14,12 +14,13 @@ import {
AbortError, AbortError,
isCompleteResponse, isCompleteResponse,
isErrorResponse, isErrorResponse,
isPartialResponse,
} from '../../../../../../../src/plugins/data/common'; } from '../../../../../../../src/plugins/data/common';
import { import {
EqlSearchStrategyRequest, EqlSearchStrategyRequest,
EqlSearchStrategyResponse, EqlSearchStrategyResponse,
} from '../../../../../data_enhanced/common'; } from '../../../../../data_enhanced/common';
import { getEqlAggsData, getSequenceAggs } from './helpers'; import { formatInspect, getEqlAggsData } from './helpers';
import { EqlPreviewResponse, EqlPreviewRequest, Source } from './types'; import { EqlPreviewResponse, EqlPreviewRequest, Source } from './types';
import { hasEqlSequenceQuery } from '../../../../common/detection_engine/utils'; import { hasEqlSequenceQuery } from '../../../../common/detection_engine/utils';
import { EqlSearchResponse } from '../../../../common/detection_engine/types'; import { EqlSearchResponse } from '../../../../common/detection_engine/types';
@ -106,13 +107,37 @@ export const useEqlPreview = (): [
if (isCompleteResponse(res)) { if (isCompleteResponse(res)) {
if (!didCancel.current) { if (!didCancel.current) {
setLoading(false); setLoading(false);
if (hasEqlSequenceQuery(query)) {
setResponse(getSequenceAggs(res, refetch.current)); setResponse((prev) => {
} else { const { inspect, ...rest } = getEqlAggsData(
setResponse(getEqlAggsData(res, interval, to, refetch.current)); res,
} interval,
to,
refetch.current,
index,
hasEqlSequenceQuery(query)
);
const inspectDsl = prev.inspect.dsl[0] ? prev.inspect.dsl : inspect.dsl;
const inspectResp = prev.inspect.response[0]
? prev.inspect.response
: inspect.response;
return {
...prev,
...rest,
inspect: {
dsl: inspectDsl,
response: inspectResp,
},
};
});
} }
unsubscribeStream.current.next(); unsubscribeStream.current.next();
} else if (isPartialResponse(res)) {
// TODO: Eql search strategy partial responses return a value under meta.params.body
// but the final/complete response does not, that's why the inspect values are set here
setResponse((prev) => ({ ...prev, inspect: formatInspect(res, index) }));
} else if (isErrorResponse(res)) { } else if (isErrorResponse(res)) {
setLoading(false); setLoading(false);
notifications.toasts.addWarning(i18n.EQL_PREVIEW_FETCH_FAILURE); notifications.toasts.addWarning(i18n.EQL_PREVIEW_FETCH_FAILURE);

View file

@ -52,7 +52,7 @@ describe('PreviewCustomQueryHistogram', () => {
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect( expect(
wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').at(0).prop('subtitle') wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
}); });
test('it configures data and subtitle', () => { test('it configures data and subtitle', () => {

View file

@ -54,7 +54,7 @@ export const PreviewCustomQueryHistogram = ({
const subtitle = useMemo( const subtitle = useMemo(
(): string => (): string =>
isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount] [isLoading, totalCount]
); );
@ -67,7 +67,7 @@ export const PreviewCustomQueryHistogram = ({
barConfig={barConfig} barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE} title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle} subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading} isLoading={isLoading}
data-test-subj="queryPreviewCustomHistogram" data-test-subj="queryPreviewCustomHistogram"
/> />

View file

@ -39,7 +39,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram <PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z" to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z" from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[]} data={[]}
totalCount={0} totalCount={0}
inspect={{ dsl: [], response: [] }} inspect={{ dsl: [], response: [] }}
@ -53,7 +52,7 @@ describe('PreviewEqlQueryHistogram', () => {
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect( expect(
wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').at(0).prop('subtitle') wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
}); });
test('it configures data and subtitle', () => { test('it configures data and subtitle', () => {
@ -63,7 +62,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram <PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z" to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z" from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[ data={[
{ x: 1602247050000, y: 2314, g: 'All others' }, { x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' }, { x: 1602247162500, y: 3471, g: 'All others' },
@ -115,7 +113,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram <PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z" to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z" from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[]} data={[]}
totalCount={0} totalCount={0}
inspect={{ dsl: ['some dsl'], response: ['query response'] }} inspect={{ dsl: ['some dsl'], response: ['query response'] }}
@ -133,4 +130,32 @@ describe('PreviewEqlQueryHistogram', () => {
refetch: mockRefetch, refetch: mockRefetch,
}); });
}); });
test('it displays histogram', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
{ x: 1602247275000, y: 3369, g: 'All others' },
]}
totalCount={9154}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists()
).toBeFalsy();
expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy();
});
}); });

View file

@ -15,7 +15,6 @@ import {
} from '../../../../common/components/charts/common'; } from '../../../../common/components/charts/common';
import { InspectQuery } from '../../../../common/store/inputs/model'; import { InspectQuery } from '../../../../common/store/inputs/model';
import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { hasEqlSequenceQuery } from '../../../../../common/detection_engine/utils';
import { inputsModel } from '../../../../common/store'; import { inputsModel } from '../../../../common/store';
import { PreviewHistogram } from './histogram'; import { PreviewHistogram } from './histogram';
@ -26,7 +25,6 @@ interface PreviewEqlQueryHistogramProps {
from: string; from: string;
totalCount: number; totalCount: number;
isLoading: boolean; isLoading: boolean;
query: string;
data: ChartData[]; data: ChartData[];
inspect: InspectQuery; inspect: InspectQuery;
refetch: inputsModel.Refetch; refetch: inputsModel.Refetch;
@ -36,7 +34,6 @@ export const PreviewEqlQueryHistogram = ({
from, from,
to, to,
totalCount, totalCount,
query,
data, data,
inspect, inspect,
refetch, refetch,
@ -50,14 +47,11 @@ export const PreviewEqlQueryHistogram = ({
} }
}, [setQuery, inspect, isInitializing, refetch]); }, [setQuery, inspect, isInitializing, refetch]);
const barConfig = useMemo( const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]);
(): ChartSeriesConfigs => getHistogramConfig(to, from, hasEqlSequenceQuery(query)),
[from, to, query]
);
const subtitle = useMemo( const subtitle = useMemo(
(): string => (): string =>
isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount] [isLoading, totalCount]
); );
@ -70,7 +64,7 @@ export const PreviewEqlQueryHistogram = ({
barConfig={barConfig} barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE} title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle} subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER_EQL} disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER_EQL}
isLoading={isLoading} isLoading={isLoading}
data-test-subj="queryPreviewEqlHistogram" data-test-subj="queryPreviewEqlHistogram"
/> />

View file

@ -16,13 +16,13 @@ import { FieldValueQueryBar } from '../query_bar';
import { ESQuery } from '../../../../../common/typed_json'; import { ESQuery } from '../../../../../common/typed_json';
import { Filter } from '../../../../../../../../src/plugins/data/common/es_query'; import { Filter } from '../../../../../../../../src/plugins/data/common/es_query';
export const HITS_THRESHOLD: Record<string, number> = { /**
h: 1, * Determines whether or not to display noise warning.
d: 24, * Is considered noisy if alerts/hour rate > 1
M: 730, * @param hits Total query search hits
}; * @param timeframe Range selected by user (last hour, day...)
*/
export const isNoisy = (hits: number, timeframe: Unit) => { export const isNoisy = (hits: number, timeframe: Unit): boolean => {
if (timeframe === 'h') { if (timeframe === 'h') {
return hits > 1; return hits > 1;
} else if (timeframe === 'd') { } else if (timeframe === 'd') {
@ -34,6 +34,12 @@ export const isNoisy = (hits: number, timeframe: Unit) => {
return false; return false;
}; };
/**
* Determines what timerange options to show.
* Eql sequence queries tend to be slower, so decided
* not to include the last month option.
* @param ruleType
*/
export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => {
if (ruleType === 'eql') { if (ruleType === 'eql') {
return [ return [
@ -49,6 +55,13 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => {
} }
}; };
/**
* Quick little helper to extract the query info from the
* queryBar object.
* @param queryBar Object containing all query info
* @param index Indices searched
* @param ruleType
*/
export const getInfoFromQueryBar = ( export const getInfoFromQueryBar = (
queryBar: FieldValueQueryBar, queryBar: FieldValueQueryBar,
index: string[], index: string[],
@ -88,10 +101,15 @@ export const getInfoFromQueryBar = (
} }
}; };
/**
* Config passed into elastic-charts settings.
* @param to
* @param from
*/
export const getHistogramConfig = ( export const getHistogramConfig = (
to: string, to: string,
from: string, from: string,
showLegend: boolean = false showLegend = false
): ChartSeriesConfigs => { ): ChartSeriesConfigs => {
return { return {
series: { series: {
@ -131,7 +149,11 @@ export const getHistogramConfig = (
}; };
}; };
export const getThresholdHistogramConfig = (height: number | undefined): ChartSeriesConfigs => { /**
* Threshold histogram is displayed a bit differently,
* x-axis is not time based, but ordinal.
*/
export const getThresholdHistogramConfig = (): ChartSeriesConfigs => {
return { return {
series: { series: {
xScaleType: ScaleType.Ordinal, xScaleType: ScaleType.Ordinal,
@ -165,6 +187,6 @@ export const getThresholdHistogramConfig = (height: number | undefined): ChartSe
}, },
}, },
}, },
customHeight: height ?? 200, customHeight: 200,
}; };
}; };

View file

@ -13,9 +13,13 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { TestProviders } from '../../../../common/mock'; import { TestProviders } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana'; import { useKibana } from '../../../../common/lib/kibana';
import { PreviewQuery } from './'; import { PreviewQuery } from './';
import { getMockResponse } from '../../../../common/hooks/eql/helpers.test'; import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock';
import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram';
import { useEqlPreview } from '../../../../common/hooks/eql/';
jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/matrix_histogram');
jest.mock('../../../../common/hooks/eql/');
describe('PreviewQuery', () => { describe('PreviewQuery', () => {
beforeEach(() => { beforeEach(() => {
@ -23,7 +27,33 @@ describe('PreviewQuery', () => {
useKibana().services.notifications.toasts.addWarning = jest.fn(); useKibana().services.notifications.toasts.addWarning = jest.fn();
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); (useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
}); });
afterEach(() => { afterEach(() => {
@ -121,6 +151,42 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
}); });
test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when rule type is saved_query and preview button clicked', () => { test('it renders query histogram when rule type is saved_query and preview button clicked', () => {
const wrapper = mount( const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -175,6 +241,42 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeTruthy();
}); });
test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="eql"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => {
const wrapper = mount( const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -192,16 +294,70 @@ describe('PreviewQuery', () => {
</ThemeProvider> </ThemeProvider>
); );
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [{ key: 'siem-kibana', doc_count: 500 }],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1); expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
}); });
test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{ field: 'agent.hostname', value: 200 }}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [
{ key: 'siem-kibana', doc_count: 200 },
{ key: 'siem-windows', doc_count: 300 },
],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => { test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => {
const wrapper = mount( const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -255,4 +411,33 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
}); });
test('it hides histogram when timeframe changes', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeTruthy();
wrapper
.find('[data-test-subj="queryPreviewTimeframeSelect"] select')
.at(0)
.simulate('change', { target: { value: 'd' } });
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy();
});
}); });

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { Fragment, useCallback, useEffect, useReducer } from 'react'; import React, { Fragment, useCallback, useEffect, useReducer, useRef } from 'react';
import { Unit } from '@elastic/datemath'; import { Unit } from '@elastic/datemath';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@ -110,29 +110,31 @@ export const PreviewQuery = ({
{ inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets }, { inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets },
startNonEql, startNonEql,
] = useMatrixHistogram({ ] = useMatrixHistogram({
errorMessage: i18n.PREVIEW_QUERY_ERROR, errorMessage: i18n.QUERY_PREVIEW_ERROR,
endDate: fromTime, endDate: fromTime,
startDate: toTime, startDate: toTime,
filterQuery: queryFilter, filterQuery: queryFilter,
indexNames: index, indexNames: index,
histogramType: MatrixHistogramType.events, histogramType: MatrixHistogramType.events,
stackByField: 'event.category', stackByField: 'event.category',
threshold, threshold: ruleType === 'threshold' ? threshold : undefined,
skip: true, skip: true,
}); });
const setQueryInfo = useCallback( const setQueryInfo = useCallback(
(queryBar: FieldValueQueryBar | undefined): void => { (queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => {
dispatch({ dispatch({
type: 'setQueryInfo', type: 'setQueryInfo',
queryBar, queryBar,
index, index: indices,
ruleType, ruleType: type,
}); });
}, },
[dispatch, index, ruleType] [dispatch]
); );
const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo));
const setTimeframeSelect = useCallback( const setTimeframeSelect = useCallback(
(selection: Unit): void => { (selection: Unit): void => {
dispatch({ dispatch({
@ -190,11 +192,9 @@ export const PreviewQuery = ({
[dispatch] [dispatch]
); );
useEffect((): void => { useEffect(() => {
const debounced = debounce(1000, setQueryInfo); debouncedSetQueryInfo.current(query, index, ruleType);
}, [index, query, ruleType]);
debounced(query);
}, [setQueryInfo, query]);
useEffect((): void => { useEffect((): void => {
setThresholdValues(threshold, ruleType); setThresholdValues(threshold, ruleType);
@ -205,12 +205,32 @@ export const PreviewQuery = ({
}, [ruleType, setRuleTypeChange]); }, [ruleType, setRuleTypeChange]);
useEffect((): void => { useEffect((): void => {
const totalHits = ruleType === 'eql' ? eqlQueryTotal : matrixHistTotal; switch (ruleType) {
case 'eql':
if (isNoisy(totalHits, timeframe)) { if (isNoisy(eqlQueryTotal, timeframe)) {
setNoiseWarning(); setNoiseWarning();
}
break;
case 'threshold':
const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal;
if (isNoisy(totalHits, timeframe)) {
setNoiseWarning();
}
break;
default:
if (isNoisy(matrixHistTotal, timeframe)) {
setNoiseWarning();
}
} }
}, [timeframe, matrixHistTotal, eqlQueryTotal, ruleType, setNoiseWarning]); }, [
timeframe,
matrixHistTotal,
eqlQueryTotal,
ruleType,
setNoiseWarning,
thresholdFieldExists,
buckets.length,
]);
const handlePreviewEqlQuery = useCallback( const handlePreviewEqlQuery = useCallback(
(to: string, from: string): void => { (to: string, from: string): void => {
@ -263,8 +283,9 @@ export const PreviewQuery = ({
options={timeframeOptions} options={timeframeOptions}
value={timeframe} value={timeframe}
onChange={handleSelectPreviewTimeframe} onChange={handleSelectPreviewTimeframe}
aria-label={i18n.PREVIEW_SELECT_ARIA} aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA}
disabled={isDisabled} disabled={isDisabled}
data-test-subj="queryPreviewTimeframeSelect"
/> />
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
@ -276,7 +297,7 @@ export const PreviewQuery = ({
onClick={handlePreviewClicked} onClick={handlePreviewClicked}
data-test-subj="queryPreviewButton" data-test-subj="queryPreviewButton"
> >
{i18n.PREVIEW_LABEL} {i18n.QUERY_PREVIEW_BUTTON}
</PreviewButton> </PreviewButton>
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
@ -307,7 +328,6 @@ export const PreviewQuery = ({
<PreviewEqlQueryHistogram <PreviewEqlQueryHistogram
to={toTime} to={toTime}
from={fromTime} from={fromTime}
query={queryString}
totalCount={eqlQueryTotal} totalCount={eqlQueryTotal}
data={eqlQueryData} data={eqlQueryData}
inspect={eqlQueryInspect} inspect={eqlQueryInspect}

View file

@ -33,9 +33,9 @@ describe('queryPreviewReducer', () => {
expect(update).toEqual(initialState); expect(update).toEqual(initialState);
}); });
test('should reset showHistogram and warnings if queryBar undefined', () => { test('should reset showHistogram if queryBar undefined', () => {
const update = reducer( const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['uh oh'] }, { ...initialState, showHistogram: true },
{ {
type: 'setQueryInfo', type: 'setQueryInfo',
queryBar: undefined, queryBar: undefined,
@ -44,11 +44,10 @@ describe('queryPreviewReducer', () => {
} }
); );
expect(update.warnings).toEqual([]);
expect(update.showHistogram).toBeFalsy(); expect(update.showHistogram).toBeFalsy();
}); });
test('should reset showHistogram and warnings if queryBar defined', () => { test('should reset showHistogram if queryBar defined', () => {
const update = reducer( const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['uh oh'] }, { ...initialState, showHistogram: true, warnings: ['uh oh'] },
{ {
@ -62,7 +61,6 @@ describe('queryPreviewReducer', () => {
} }
); );
expect(update.warnings).toEqual([]);
expect(update.showHistogram).toBeFalsy(); expect(update.showHistogram).toBeFalsy();
}); });

View file

@ -82,13 +82,11 @@ export const queryPreviewReducer = () => (state: State, action: Action): State =
filters, filters,
queryFilter, queryFilter,
showHistogram: false, showHistogram: false,
warnings: [],
}; };
} }
return { return {
...state, ...state,
warnings: [],
showHistogram: false, showHistogram: false,
}; };
} }

View file

@ -56,12 +56,12 @@ export const PreviewThresholdQueryHistogram = ({
}; };
}, [buckets]); }, [buckets]);
const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(200), []); const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []);
const subtitle = useMemo( const subtitle = useMemo(
(): string => (): string =>
isLoading isLoading
? i18n.PREVIEW_SUBTITLE_LOADING ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING
: i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount), : i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount),
[isLoading, totalCount] [isLoading, totalCount]
); );
@ -73,7 +73,7 @@ export const PreviewThresholdQueryHistogram = ({
barConfig={barConfig} barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE} title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle} subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading} isLoading={isLoading}
data-test-subj="thresholdQueryPreviewHistogram" data-test-subj="thresholdQueryPreviewHistogram"
/> />

View file

@ -6,14 +6,14 @@
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
export const PREVIEW_LABEL = i18n.translate( export const QUERY_PREVIEW_BUTTON = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewQueryLabel', 'xpack.securitySolution.stepDefineRule.previewQueryButton',
{ {
defaultMessage: 'Preview results', defaultMessage: 'Preview results',
} }
); );
export const PREVIEW_SELECT_ARIA = i18n.translate( export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel', 'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel',
{ {
defaultMessage: 'Query preview timeframe select', defaultMessage: 'Query preview timeframe select',
@ -85,14 +85,14 @@ export const QUERY_PREVIEW_NO_HITS = i18n.translate(
} }
); );
export const PREVIEW_QUERY_ERROR = i18n.translate( export const QUERY_PREVIEW_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewError', 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewError',
{ {
defaultMessage: 'Error fetching preview', defaultMessage: 'Error fetching preview',
} }
); );
export const PREVIEW_QUERY_DISCLAIMER = i18n.translate( export const QUERY_PREVIEW_DISCLAIMER = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimer', 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimer',
{ {
defaultMessage: defaultMessage:
@ -100,7 +100,7 @@ export const PREVIEW_QUERY_DISCLAIMER = i18n.translate(
} }
); );
export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate( export const QUERY_PREVIEW_DISCLAIMER_EQL = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimerEql', 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimerEql',
{ {
defaultMessage: defaultMessage:
@ -108,26 +108,24 @@ export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate(
} }
); );
export const PREVIEW_WARNING_CAP_HIT = (cap: number) => export const QUERY_PREVIEW_SUBTITLE_LOADING = i18n.translate(
i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning',
{
values: { cap },
defaultMessage:
'Hit query cap size of {cap}. This query could produce more hits than the {cap} shown.',
}
);
export const PREVIEW_WARNING_TIMESTAMP = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning',
{
defaultMessage: 'Unable to find "@timestamp" field on events.',
}
);
export const PREVIEW_SUBTITLE_LOADING = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading', 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading',
{ {
defaultMessage: '...loading', defaultMessage: '...loading',
} }
); );
export const QUERY_PREVIEW_EQL_SEQUENCE_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceTitle',
{
defaultMessage: 'No histogram available',
}
);
export const QUERY_PREVIEW_EQL_SEQUENCE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceDescription',
{
defaultMessage:
'No histogram is available at this time for EQL sequence queries. You can use the inspect in the top right corner to view query details.',
}
);

View file

@ -5,7 +5,7 @@
*/ */
import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui';
import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
// Prefer importing entire lodash library, e.g. import { get } from "lodash" // Prefer importing entire lodash library, e.g. import { get } from "lodash"
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
@ -53,7 +53,7 @@ import {
import { EqlQueryBar } from '../eql_query_bar'; import { EqlQueryBar } from '../eql_query_bar';
import { ThreatMatchInput } from '../threatmatch_input'; import { ThreatMatchInput } from '../threatmatch_input';
import { useFetchIndex } from '../../../../common/containers/source'; import { useFetchIndex } from '../../../../common/containers/source';
import { PreviewQuery } from '../query_preview'; import { PreviewQuery, Threshold } from '../query_preview';
const CommonUseField = getUseField({ component: Field }); const CommonUseField = getUseField({ component: Field });
@ -210,6 +210,12 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
setOpenTimelineSearch(false); setOpenTimelineSearch(false);
}, []); }, []);
const thresholdFormValue = useMemo((): Threshold | undefined => {
return formThresholdValue != null && formThresholdField != null
? { value: formThresholdValue, field: formThresholdField[0] }
: undefined;
}, [formThresholdField, formThresholdValue]);
const ThresholdInputChildren = useCallback( const ThresholdInputChildren = useCallback(
({ thresholdField, thresholdValue }) => ( ({ thresholdField, thresholdValue }) => (
<ThresholdInput <ThresholdInput
@ -403,11 +409,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
index={index} index={index}
query={formQuery} query={formQuery}
isDisabled={queryBarQuery.trim() === '' || !isQueryBarValid || index.length === 0} isDisabled={queryBarQuery.trim() === '' || !isQueryBarValid || index.length === 0}
threshold={ threshold={thresholdFormValue}
formThresholdValue != null && formThresholdField != null
? { value: formThresholdValue, field: formThresholdField[0] }
: undefined
}
/> />
</> </>
)} )}

View file

@ -17356,9 +17356,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "クエリプレビュー", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "クエリプレビュー",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "イズ警告このルールではイズが多く生じる可能性があります。クエリを絞り込むことを検討してください。これは1時間ごとに1アラートという線形進行に基づいています。", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "イズ警告このルールではイズが多く生じる可能性があります。クエリを絞り込むことを検討してください。これは1時間ごとに1アラートという線形進行に基づいています。",
"xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "ヒットが見つかりませんでした。", "xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "ヒットが見つかりませんでした。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "クエリの上限サイズ{cap}に達しました。このクエリは表示されている{cap}を超えるヒットを生成できませんでした。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} {buckets, plural, =1 {固有のヒット} other {固有のヒット}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} {buckets, plural, =1 {固有のヒット} other {固有のヒット}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "イベントで「@timestamp」フィールドが見つかりません",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} {hits, plural, =1 {ヒット} other {ヒット}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} {hits, plural, =1 {ヒット} other {ヒット}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー",
@ -18356,7 +18354,6 @@
"xpack.securitySolution.security.title": "セキュリティ", "xpack.securitySolution.security.title": "セキュリティ",
"xpack.securitySolution.source.destination.packetsLabel": "パケット", "xpack.securitySolution.source.destination.packetsLabel": "パケット",
"xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "クエリプレビュータイムフレーム選択", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "クエリプレビュータイムフレーム選択",
"xpack.securitySolution.stepDefineRule.previewQueryLabel": "結果を表示",
"xpack.securitySolution.system.acceptedAConnectionViaDescription": "次の手段で接続を受け付けました。", "xpack.securitySolution.system.acceptedAConnectionViaDescription": "次の手段で接続を受け付けました。",
"xpack.securitySolution.system.acceptedDescription": "以下を経由してユーザーを受け入れました。", "xpack.securitySolution.system.acceptedDescription": "以下を経由してユーザーを受け入れました。",
"xpack.securitySolution.system.attemptedLoginDescription": "以下を経由してログインを試行しました:", "xpack.securitySolution.system.attemptedLoginDescription": "以下を経由してログインを試行しました:",

View file

@ -17374,9 +17374,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "查询预览", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "查询预览",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "噪音警告:此规则可能会导致大量噪音。考虑缩小您的查询范围。这基于每小时 1 条告警的线性级数。", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "噪音警告:此规则可能会导致大量噪音。考虑缩小您的查询范围。这基于每小时 1 条告警的线性级数。",
"xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "找不到任何命中。", "xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "找不到任何命中。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "命中查询上限大小为 {cap}。此查询生成的命中数可能大于显示的 {cap}。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} 个{buckets, plural, =1 {唯一命中} other {唯一命中}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} 个{buckets, plural, =1 {唯一命中} other {唯一命中}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "在事件中找不到“@timestamp”字段。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} 个{hits, plural, =1 {命中} other {命中}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} 个{hits, plural, =1 {命中} other {命中}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览",
@ -18375,7 +18373,6 @@
"xpack.securitySolution.security.title": "安全", "xpack.securitySolution.security.title": "安全",
"xpack.securitySolution.source.destination.packetsLabel": "pkts", "xpack.securitySolution.source.destination.packetsLabel": "pkts",
"xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "查询预览时间范围选择", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "查询预览时间范围选择",
"xpack.securitySolution.stepDefineRule.previewQueryLabel": "预览结果",
"xpack.securitySolution.system.acceptedAConnectionViaDescription": "已接受连接 - 通过", "xpack.securitySolution.system.acceptedAConnectionViaDescription": "已接受连接 - 通过",
"xpack.securitySolution.system.acceptedDescription": "已接受该用户 - 通过", "xpack.securitySolution.system.acceptedDescription": "已接受该用户 - 通过",
"xpack.securitySolution.system.attemptedLoginDescription": "已尝试登录 - 通过", "xpack.securitySolution.system.attemptedLoginDescription": "已尝试登录 - 通过",