mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Alerting] Search alert (#88528)
* Adding es query alert type to server with commented out executor * Adding skeleton es query alert to client with JSON editor. Pulled out index popoover into component for reuse between index threshold and es query alert types * Implementing alert executor that performs query and matches condition against doc count * Added tests for server side alert type * Updated alert executor to de-duplicate matches and create instance for every document if threshold is not defined * Moving more index popover code out of index threshold and es query expression components * Ability to remove threshold condition from es query alert * Validation tests * Adding ability to test out query. Need to add error handling and it looks ugly * Fixing bug with creating alert with threshold and i18n * wip * Fixing tests * Simplifying executor logic to only handle threshold and store hits in action context * Adding functional test for es query alert * Types * Adding functional test for query testing * Fixing unit test * Adding link to ES docs. Cleaning up logger statements * Adding docs * Updating docs based on feedback * PR fixes * Using ES client typings * Fixing unit test * Fixing copy based on comments * Fixing copy based on comments * Fixing bug in index select popover * Fixing unit tests * Making track_total_hits configurable * Fixing functional test * PR fixes * Added unit test * Removing unused import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
9733d2fdaa
commit
049135192e
45 changed files with 3073 additions and 295 deletions
|
@ -8,7 +8,7 @@ This section covers stack alerts. For domain-specific alert types, refer to the
|
|||
Users will need `all` access to the *Stack Alerts* feature to be able to create and edit any of the alerts listed below.
|
||||
See <<kibana-feature-privileges, feature privileges>> for more information on configuring roles that provide access to this feature.
|
||||
|
||||
Currently {kib} provides one stack alert: the <<alert-type-index-threshold>> type.
|
||||
Currently {kib} provides two stack alerts: <<alert-type-index-threshold>> and <<alert-type-es-query>>.
|
||||
|
||||
[float]
|
||||
[[alert-type-index-threshold]]
|
||||
|
@ -112,6 +112,47 @@ You can interactively change the time window and observe the effect it has on th
|
|||
[role="screenshot"]
|
||||
image::images/alert-types-index-threshold-example-comparison.png[Comparing two time windows]
|
||||
|
||||
[float]
|
||||
[[alert-type-es-query]]
|
||||
=== ES query
|
||||
|
||||
The ES query alert type is designed to run a user-configured {es} query over indices, compare the number of matches to a configured threshold, and schedule
|
||||
actions to run when the threshold condition is met.
|
||||
|
||||
[float]
|
||||
==== Creating the alert
|
||||
|
||||
An ES query alert can be created from the *Create* button in the <<alert-management, alert management UI>>. Fill in the <<defining-alerts-general-details, general alert details>>, then select *ES query*.
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/alert-types-es-query-select.png[Choosing an ES query alert type]
|
||||
|
||||
[float]
|
||||
==== Defining the conditions
|
||||
|
||||
The ES query alert has 4 clauses that define the condition to detect.
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/alert-types-es-query-conditions.png[Four clauses define the condition to detect]
|
||||
|
||||
Index:: This clause requires an *index or index pattern* and a *time field* that will be used for the *time window*.
|
||||
ES query:: This clause specifies the ES DSL query to execute. The number of documents that match this query will be evaulated against the threshold
|
||||
condition. Aggregations are not supported at this time.
|
||||
Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold.
|
||||
Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <<defining-alerts-general-details, general alert details>>, to avoid gaps in detection.
|
||||
|
||||
[float]
|
||||
==== Testing your query
|
||||
|
||||
Use the *Test query* feature to verify that your query DSL is valid.
|
||||
|
||||
When your query is valid:: Valid queries will be executed against the configured *index* using the configured *time window*. The number of documents that
|
||||
match the query will be displayed.
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/alert-types-es-query-valid.png[Test ES query returns number of matches when valid]
|
||||
|
||||
When your query is invalid:: An error message is shown if the query is invalid.
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/alert-types-es-query-invalid.png[Test ES query shows error when invalid]
|
BIN
docs/user/alerting/images/alert-types-es-query-conditions.png
Normal file
BIN
docs/user/alerting/images/alert-types-es-query-conditions.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 95 KiB |
BIN
docs/user/alerting/images/alert-types-es-query-invalid.png
Normal file
BIN
docs/user/alerting/images/alert-types-es-query-invalid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
BIN
docs/user/alerting/images/alert-types-es-query-select.png
Normal file
BIN
docs/user/alerting/images/alert-types-es-query-select.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
BIN
docs/user/alerting/images/alert-types-es-query-valid.png
Normal file
BIN
docs/user/alerting/images/alert-types-es-query-valid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 78 KiB |
|
@ -15,6 +15,7 @@ export * from './alert_instance_summary';
|
|||
export * from './builtin_action_groups';
|
||||
export * from './disabled_action_groups';
|
||||
export * from './alert_notify_when_type';
|
||||
export * from './parse_duration';
|
||||
|
||||
export interface AlertingFrameworkHealth {
|
||||
isSufficientlySecure: boolean;
|
||||
|
|
|
@ -0,0 +1,398 @@
|
|||
/*
|
||||
* 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 { buildSortedEventsQuery, BuildSortedEventsQuery } from './build_sorted_events_query';
|
||||
import type { Writable } from '@kbn/utility-types';
|
||||
|
||||
const DefaultQuery: Writable<Partial<BuildSortedEventsQuery>> = {
|
||||
index: ['index-name'],
|
||||
from: '2021-01-01T00:00:10.123Z',
|
||||
to: '2021-01-23T12:00:50.321Z',
|
||||
filter: {},
|
||||
size: 100,
|
||||
timeField: 'timefield',
|
||||
};
|
||||
|
||||
describe('buildSortedEventsQuery', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let query: any;
|
||||
beforeEach(() => {
|
||||
query = { ...DefaultQuery };
|
||||
});
|
||||
|
||||
test('it builds a filter with given date range', () => {
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it does not include searchAfterSortId if it is an empty string', () => {
|
||||
query.searchAfterSortId = '';
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it includes searchAfterSortId if it is a valid string', () => {
|
||||
const sortId = '123456789012';
|
||||
query.searchAfterSortId = sortId;
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
search_after: [sortId],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it includes searchAfterSortId if it is a valid number', () => {
|
||||
const sortId = 123456789012;
|
||||
query.searchAfterSortId = sortId;
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
search_after: [sortId],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it includes aggregations if provided', () => {
|
||||
query.aggs = {
|
||||
tags: {
|
||||
terms: {
|
||||
field: 'tag',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
tags: {
|
||||
terms: {
|
||||
field: 'tag',
|
||||
},
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it uses sortOrder if specified', () => {
|
||||
query.sortOrder = 'desc';
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: false,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it uses track_total_hits if specified', () => {
|
||||
query.track_total_hits = true;
|
||||
expect(buildSortedEventsQuery(query)).toEqual({
|
||||
allowNoIndices: true,
|
||||
index: ['index-name'],
|
||||
size: 100,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: true,
|
||||
body: {
|
||||
docvalue_fields: [
|
||||
{
|
||||
field: 'timefield',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{},
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timefield: {
|
||||
gte: '2021-01-01T00:00:10.123Z',
|
||||
lte: '2021-01-23T12:00:50.321Z',
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
timefield: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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 { ESSearchBody, ESSearchRequest } from '../../../typings/elasticsearch';
|
||||
import { SortOrder } from '../../../typings/elasticsearch/aggregations';
|
||||
|
||||
type BuildSortedEventsQueryOpts = Pick<ESSearchBody, 'aggs' | 'track_total_hits'> &
|
||||
Pick<Required<ESSearchRequest>, 'index' | 'size'>;
|
||||
|
||||
export interface BuildSortedEventsQuery extends BuildSortedEventsQueryOpts {
|
||||
filter: unknown;
|
||||
from: string;
|
||||
to: string;
|
||||
sortOrder?: SortOrder | undefined;
|
||||
searchAfterSortId: string | number | undefined;
|
||||
timeField: string;
|
||||
}
|
||||
|
||||
export const buildSortedEventsQuery = ({
|
||||
aggs,
|
||||
index,
|
||||
from,
|
||||
to,
|
||||
filter,
|
||||
size,
|
||||
searchAfterSortId,
|
||||
sortOrder,
|
||||
timeField,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
track_total_hits,
|
||||
}: BuildSortedEventsQuery): ESSearchRequest => {
|
||||
const sortField = timeField;
|
||||
const docFields = [timeField].map((tstamp) => ({
|
||||
field: tstamp,
|
||||
format: 'strict_date_optional_time',
|
||||
}));
|
||||
|
||||
const rangeFilter: unknown[] = [
|
||||
{
|
||||
range: {
|
||||
[timeField]: {
|
||||
lte: to,
|
||||
gte: from,
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const filterWithTime = [filter, { bool: { filter: rangeFilter } }];
|
||||
|
||||
const searchQuery = {
|
||||
allowNoIndices: true,
|
||||
index,
|
||||
size,
|
||||
ignoreUnavailable: true,
|
||||
track_total_hits: track_total_hits ?? false,
|
||||
body: {
|
||||
docvalue_fields: docFields,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...filterWithTime,
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(aggs ? { aggs } : {}),
|
||||
sort: [
|
||||
{
|
||||
[sortField]: {
|
||||
order: sortOrder ?? 'asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (searchAfterSortId) {
|
||||
return {
|
||||
...searchQuery,
|
||||
body: {
|
||||
...searchQuery.body,
|
||||
search_after: [searchAfterSortId],
|
||||
},
|
||||
};
|
||||
}
|
||||
return searchQuery;
|
||||
};
|
|
@ -5,5 +5,6 @@
|
|||
"kibanaVersion": "kibana",
|
||||
"requiredPlugins": ["alerts", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"],
|
||||
"configPath": ["xpack", "stack_alerts"],
|
||||
"requiredBundles": ["esUiShared"],
|
||||
"ui": true
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { IndexSelectPopover } from './index_select_popover';
|
||||
|
||||
jest.mock('../../../../triggers_actions_ui/public', () => ({
|
||||
getIndexPatterns: () => {
|
||||
return ['index1', 'index2'];
|
||||
},
|
||||
firstFieldOption: () => {
|
||||
return { text: 'Select a field', value: '' };
|
||||
},
|
||||
getTimeFieldOptions: () => {
|
||||
return [
|
||||
{
|
||||
text: '@timestamp',
|
||||
value: '@timestamp',
|
||||
},
|
||||
];
|
||||
},
|
||||
getFields: () => {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: '@timestamp',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'field',
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
},
|
||||
getIndexOptions: () => {
|
||||
return Promise.resolve([
|
||||
{
|
||||
label: 'indexOption',
|
||||
options: [
|
||||
{
|
||||
label: 'index1',
|
||||
value: 'index1',
|
||||
},
|
||||
{
|
||||
label: 'index2',
|
||||
value: 'index2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('IndexSelectPopover', () => {
|
||||
const props = {
|
||||
index: [],
|
||||
esFields: [],
|
||||
timeField: undefined,
|
||||
errors: {
|
||||
index: [],
|
||||
timeField: [],
|
||||
},
|
||||
onIndexChange: jest.fn(),
|
||||
onTimeFieldChange: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('renders closed popover initially and opens on click', async () => {
|
||||
const wrapper = mountWithIntl(<IndexSelectPopover {...props} />);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeFalsy();
|
||||
|
||||
wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click');
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders search input', async () => {
|
||||
const wrapper = mountWithIntl(<IndexSelectPopover {...props} />);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy();
|
||||
wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click');
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy();
|
||||
const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]');
|
||||
expect(indexSearchBoxValue.first().props().value).toEqual('');
|
||||
|
||||
const indexComboBox = wrapper.find('#indexSelectSearchBox');
|
||||
indexComboBox.first().simulate('click');
|
||||
const event = { target: { value: 'indexPattern1' } };
|
||||
indexComboBox.find('input').first().simulate('change', event);
|
||||
|
||||
const updatedIndexSearchValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]');
|
||||
expect(updatedIndexSearchValue.first().props().value).toEqual('indexPattern1');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* 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 React, { useEffect, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isString } from 'lodash';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiExpression,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiSelect,
|
||||
} from '@elastic/eui';
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
firstFieldOption,
|
||||
getFields,
|
||||
getIndexOptions,
|
||||
getIndexPatterns,
|
||||
getTimeFieldOptions,
|
||||
IErrorObject,
|
||||
} from '../../../../triggers_actions_ui/public';
|
||||
|
||||
interface KibanaDeps {
|
||||
http: HttpSetup;
|
||||
}
|
||||
interface Props {
|
||||
index: string[];
|
||||
esFields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
normalizedType: string;
|
||||
searchable: boolean;
|
||||
aggregatable: boolean;
|
||||
}>;
|
||||
timeField: string | undefined;
|
||||
errors: IErrorObject;
|
||||
onIndexChange: (indices: string[]) => void;
|
||||
onTimeFieldChange: (timeField: string) => void;
|
||||
}
|
||||
|
||||
export const IndexSelectPopover: React.FunctionComponent<Props> = ({
|
||||
index,
|
||||
esFields,
|
||||
timeField,
|
||||
errors,
|
||||
onIndexChange,
|
||||
onTimeFieldChange,
|
||||
}) => {
|
||||
const { http } = useKibana<KibanaDeps>().services;
|
||||
|
||||
const [indexPopoverOpen, setIndexPopoverOpen] = useState(false);
|
||||
const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]);
|
||||
const [indexPatterns, setIndexPatterns] = useState([]);
|
||||
const [areIndicesLoading, setAreIndicesLoading] = useState<boolean>(false);
|
||||
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
|
||||
|
||||
useEffect(() => {
|
||||
const indexPatternsFunction = async () => {
|
||||
setIndexPatterns(await getIndexPatterns());
|
||||
};
|
||||
indexPatternsFunction();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeFields = getTimeFieldOptions(esFields);
|
||||
setTimeFieldOptions([firstFieldOption, ...timeFields]);
|
||||
}, [esFields]);
|
||||
|
||||
const renderIndices = (indices: string[]) => {
|
||||
const rows = indices.map((indexName: string, idx: number) => {
|
||||
return (
|
||||
<p key={idx}>
|
||||
{indexName}
|
||||
{idx < indices.length - 1 ? ',' : null}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
return <div>{rows}</div>;
|
||||
};
|
||||
|
||||
const closeIndexPopover = () => {
|
||||
setIndexPopoverOpen(false);
|
||||
if (timeField === undefined) {
|
||||
onTimeFieldChange('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="indexPopover"
|
||||
button={
|
||||
<EuiExpression
|
||||
display="columns"
|
||||
data-test-subj="selectIndexExpression"
|
||||
description={i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexLabel', {
|
||||
defaultMessage: 'index',
|
||||
})}
|
||||
value={index && index.length > 0 ? renderIndices(index) : firstFieldOption.text}
|
||||
isActive={indexPopoverOpen}
|
||||
onClick={() => {
|
||||
setIndexPopoverOpen(true);
|
||||
}}
|
||||
isInvalid={!(index && index.length > 0 && timeField !== '')}
|
||||
/>
|
||||
}
|
||||
isOpen={indexPopoverOpen}
|
||||
closePopover={closeIndexPopover}
|
||||
ownFocus
|
||||
anchorPosition="downLeft"
|
||||
zIndex={8000}
|
||||
display="block"
|
||||
>
|
||||
<div style={{ width: '450px' }}>
|
||||
<EuiPopoverTitle>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
{i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexButtonLabel', {
|
||||
defaultMessage: 'index',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="closePopover"
|
||||
iconType="cross"
|
||||
color="danger"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.stackAlerts.components.ui.alertParams.closeIndexPopoverLabel',
|
||||
{
|
||||
defaultMessage: 'Close',
|
||||
}
|
||||
)}
|
||||
onClick={closeIndexPopover}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopoverTitle>
|
||||
<EuiFormRow
|
||||
id="indexSelectSearchBox"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.components.ui.alertParams.indicesToQueryLabel"
|
||||
defaultMessage="Indices to query"
|
||||
/>
|
||||
}
|
||||
isInvalid={errors.index.length > 0 && index != null && index.length > 0}
|
||||
error={errors.index}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.components.ui.alertParams.howToBroadenSearchQueryDescription"
|
||||
defaultMessage="Use * to broaden your query."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
async
|
||||
isLoading={areIndicesLoading}
|
||||
isInvalid={errors.index.length > 0 && index != null && index.length > 0}
|
||||
noSuggestions={!indexOptions.length}
|
||||
options={indexOptions}
|
||||
data-test-subj="thresholdIndexesComboBox"
|
||||
selectedOptions={(index || []).map((anIndex: string) => {
|
||||
return {
|
||||
label: anIndex,
|
||||
value: anIndex,
|
||||
};
|
||||
})}
|
||||
onChange={async (selected: EuiComboBoxOptionOption[]) => {
|
||||
const selectedIndices = selected
|
||||
.map((aSelected) => aSelected.value)
|
||||
.filter<string>(isString);
|
||||
onIndexChange(selectedIndices);
|
||||
|
||||
// reset time field if indices have been reset
|
||||
if (selectedIndices.length === 0) {
|
||||
setTimeFieldOptions([firstFieldOption]);
|
||||
} else {
|
||||
const currentEsFields = await getFields(http!, selectedIndices);
|
||||
const timeFields = getTimeFieldOptions(currentEsFields);
|
||||
setTimeFieldOptions([firstFieldOption, ...timeFields]);
|
||||
}
|
||||
}}
|
||||
onSearchChange={async (search) => {
|
||||
setAreIndicesLoading(true);
|
||||
setIndexOptions(await getIndexOptions(http!, search, indexPatterns));
|
||||
setAreIndicesLoading(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!index) {
|
||||
onIndexChange([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
id="thresholdTimeField"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.components.ui.alertParams.timeFieldLabel"
|
||||
defaultMessage="Time field"
|
||||
/>
|
||||
}
|
||||
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||
error={errors.timeField}
|
||||
>
|
||||
<EuiSelect
|
||||
options={timeFieldOptions}
|
||||
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||
fullWidth
|
||||
name="thresholdTimeField"
|
||||
data-test-subj="thresholdAlertTimeFieldSelect"
|
||||
value={timeField || ''}
|
||||
onChange={(e) => {
|
||||
onTimeFieldChange(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (timeField === undefined) {
|
||||
onTimeFieldChange('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import 'brace';
|
||||
import { of } from 'rxjs';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import EsQueryAlertTypeExpression from './expression';
|
||||
import { dataPluginMock } from 'src/plugins/data/public/mocks';
|
||||
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
|
||||
import {
|
||||
DataPublicPluginStart,
|
||||
IKibanaSearchResponse,
|
||||
ISearchStart,
|
||||
} from 'src/plugins/data/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
|
||||
jest.mock('../../../../../../src/plugins/kibana_react/public');
|
||||
jest.mock('../../../../../../src/plugins/es_ui_shared/public');
|
||||
jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
|
||||
XJson: {
|
||||
useXJsonMode: jest.fn().mockReturnValue({
|
||||
convertToJson: jest.fn(),
|
||||
setXJson: jest.fn(),
|
||||
xJson: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
jest.mock('');
|
||||
jest.mock('@elastic/eui', () => {
|
||||
const original = jest.requireActual('@elastic/eui');
|
||||
|
||||
return {
|
||||
...original,
|
||||
// Mocking EuiCodeEditor, which uses React Ace under the hood
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
EuiCodeEditor: (props: any) => (
|
||||
<input
|
||||
data-test-subj="mockCodeEditor"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange={(syntheticEvent: any) => {
|
||||
props.onChange(syntheticEvent.jsonString);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../triggers_actions_ui/public', () => {
|
||||
const original = jest.requireActual('../../../../triggers_actions_ui/public');
|
||||
return {
|
||||
...original,
|
||||
getIndexPatterns: () => {
|
||||
return ['index1', 'index2'];
|
||||
},
|
||||
firstFieldOption: () => {
|
||||
return { text: 'Select a field', value: '' };
|
||||
},
|
||||
getTimeFieldOptions: () => {
|
||||
return [
|
||||
{
|
||||
text: '@timestamp',
|
||||
value: '@timestamp',
|
||||
},
|
||||
];
|
||||
},
|
||||
getFields: () => {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: '@timestamp',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'field',
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
},
|
||||
getIndexOptions: () => {
|
||||
return Promise.resolve([
|
||||
{
|
||||
label: 'indexOption',
|
||||
options: [
|
||||
{
|
||||
label: 'index1',
|
||||
value: 'index1',
|
||||
},
|
||||
{
|
||||
label: 'index2',
|
||||
value: 'index2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const createDataPluginMock = () => {
|
||||
const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
search: ISearchStart & { search: jest.MockedFunction<any> };
|
||||
};
|
||||
return dataMock;
|
||||
};
|
||||
|
||||
const dataMock = createDataPluginMock();
|
||||
const chartsStartMock = chartPluginMock.createStartContract();
|
||||
|
||||
describe('EsQueryAlertTypeExpression', () => {
|
||||
beforeAll(() => {
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
docLinks: {
|
||||
ELASTIC_WEBSITE_URL: '',
|
||||
DOC_LINK_VERSION: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function getAlertParams(overrides = {}) {
|
||||
return {
|
||||
index: ['test-index'],
|
||||
timeField: '@timestamp',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
thresholdComparator: '>',
|
||||
threshold: [0],
|
||||
timeWindowSize: 15,
|
||||
timeWindowUnit: 's',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
async function setup(alertParams: EsQueryAlertParams) {
|
||||
const errors = {
|
||||
index: [],
|
||||
esQuery: [],
|
||||
timeField: [],
|
||||
timeWindowSize: [],
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EsQueryAlertTypeExpression
|
||||
alertInterval="1m"
|
||||
alertThrottle="1m"
|
||||
alertParams={alertParams}
|
||||
setAlertParams={() => {}}
|
||||
setAlertProperty={() => {}}
|
||||
errors={errors}
|
||||
data={dataMock}
|
||||
defaultActionGroupId=""
|
||||
actionGroups={[]}
|
||||
charts={chartsStartMock}
|
||||
/>
|
||||
);
|
||||
|
||||
const update = async () =>
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
await update();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
test('should render EsQueryAlertTypeExpression with expected components', async () => {
|
||||
const wrapper = await setup(getAlertParams());
|
||||
expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy();
|
||||
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
expect(testQueryButton.exists()).toBeTruthy();
|
||||
expect(testQueryButton.prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
test('should render Test Query button disabled if alert params are invalid', async () => {
|
||||
const wrapper = await setup(getAlertParams({ timeField: null }));
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
expect(testQueryButton.exists()).toBeTruthy();
|
||||
expect(testQueryButton.prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
test('should show success message if Test Query is successful', async () => {
|
||||
const searchResponseMock$ = of<IKibanaSearchResponse>({
|
||||
rawResponse: {
|
||||
hits: {
|
||||
total: 1234,
|
||||
},
|
||||
},
|
||||
});
|
||||
dataMock.search.search.mockImplementation(() => searchResponseMock$);
|
||||
const wrapper = await setup(getAlertParams());
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
|
||||
testQueryButton.simulate('click');
|
||||
expect(dataMock.search.search).toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual(
|
||||
`Query matched 1234 documents in the last 15s.`
|
||||
);
|
||||
});
|
||||
|
||||
test('should show error message if Test Query is throws error', async () => {
|
||||
dataMock.search.search.mockImplementation(() => {
|
||||
throw new Error('What is this query');
|
||||
});
|
||||
const wrapper = await setup(getAlertParams());
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
|
||||
testQueryButton.simulate('click');
|
||||
expect(dataMock.search.search).toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,371 @@
|
|||
/*
|
||||
* 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 React, { useState, Fragment, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import 'brace/theme/github';
|
||||
import { XJsonMode } from '@kbn/ace';
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiCodeEditor,
|
||||
EuiSpacer,
|
||||
EuiFormRow,
|
||||
EuiCallOut,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { DocLinksStart, HttpSetup } from 'kibana/public';
|
||||
import { XJson } from '../../../../../../src/plugins/es_ui_shared/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
getFields,
|
||||
COMPARATORS,
|
||||
ThresholdExpression,
|
||||
ForLastExpression,
|
||||
AlertTypeParamsExpressionProps,
|
||||
} from '../../../../triggers_actions_ui/public';
|
||||
import { validateExpression } from './validation';
|
||||
import { parseDuration } from '../../../../alerts/common';
|
||||
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { IndexSelectPopover } from '../components/index_select_popover';
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
|
||||
QUERY: `{
|
||||
"query":{
|
||||
"match_all" : {}
|
||||
}
|
||||
}`,
|
||||
TIME_WINDOW_SIZE: 5,
|
||||
TIME_WINDOW_UNIT: 'm',
|
||||
THRESHOLD: [1000],
|
||||
};
|
||||
|
||||
const expressionFieldsWithValidation = [
|
||||
'index',
|
||||
'esQuery',
|
||||
'timeField',
|
||||
'threshold0',
|
||||
'threshold1',
|
||||
'timeWindowSize',
|
||||
];
|
||||
|
||||
const { useXJsonMode } = XJson;
|
||||
const xJsonMode = new XJsonMode();
|
||||
|
||||
interface KibanaDeps {
|
||||
http: HttpSetup;
|
||||
docLinks: DocLinksStart;
|
||||
}
|
||||
|
||||
export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
||||
AlertTypeParamsExpressionProps<EsQueryAlertParams>
|
||||
> = ({ alertParams, setAlertParams, setAlertProperty, errors, data }) => {
|
||||
const {
|
||||
index,
|
||||
timeField,
|
||||
esQuery,
|
||||
thresholdComparator,
|
||||
threshold,
|
||||
timeWindowSize,
|
||||
timeWindowUnit,
|
||||
} = alertParams;
|
||||
|
||||
const getDefaultParams = () => ({
|
||||
...alertParams,
|
||||
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
|
||||
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
|
||||
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
});
|
||||
|
||||
const { http, docLinks } = useKibana<KibanaDeps>().services;
|
||||
|
||||
const [esFields, setEsFields] = useState<
|
||||
Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
normalizedType: string;
|
||||
searchable: boolean;
|
||||
aggregatable: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY);
|
||||
const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>(
|
||||
getDefaultParams()
|
||||
);
|
||||
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
|
||||
const [testQueryError, setTestQueryError] = useState<string | null>(null);
|
||||
|
||||
const hasExpressionErrors = !!Object.keys(errors).find(
|
||||
(errorKey) =>
|
||||
expressionFieldsWithValidation.includes(errorKey) &&
|
||||
errors[errorKey].length >= 1 &&
|
||||
alertParams[errorKey as keyof EsQueryAlertParams] !== undefined
|
||||
);
|
||||
|
||||
const expressionErrorMessage = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
|
||||
{
|
||||
defaultMessage: 'Expression contains errors.',
|
||||
}
|
||||
);
|
||||
|
||||
const setDefaultExpressionValues = async () => {
|
||||
setAlertProperty('params', getDefaultParams());
|
||||
|
||||
setXJson(esQuery ?? DEFAULT_VALUES.QUERY);
|
||||
|
||||
if (index && index.length > 0) {
|
||||
await refreshEsFields();
|
||||
}
|
||||
};
|
||||
|
||||
const setParam = (paramField: string, paramValue: unknown) => {
|
||||
setCurrentAlertParams({
|
||||
...currentAlertParams,
|
||||
[paramField]: paramValue,
|
||||
});
|
||||
setAlertParams(paramField, paramValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultExpressionValues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const refreshEsFields = async () => {
|
||||
if (index) {
|
||||
const currentEsFields = await getFields(http, index);
|
||||
setEsFields(currentEsFields);
|
||||
}
|
||||
};
|
||||
|
||||
const hasValidationErrors = () => {
|
||||
const { errors: validationErrors } = validateExpression(currentAlertParams);
|
||||
return Object.keys(validationErrors).some(
|
||||
(key) => validationErrors[key] && validationErrors[key].length
|
||||
);
|
||||
};
|
||||
|
||||
const onTestQuery = async () => {
|
||||
if (!hasValidationErrors()) {
|
||||
setTestQueryError(null);
|
||||
setTestQueryResult(null);
|
||||
try {
|
||||
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||
const timeWindow = parseDuration(window);
|
||||
const parsedQuery = JSON.parse(esQuery);
|
||||
const now = Date.now();
|
||||
const { rawResponse } = await data.search
|
||||
.search({
|
||||
params: buildSortedEventsQuery({
|
||||
index,
|
||||
from: new Date(now - timeWindow).toISOString(),
|
||||
to: new Date(now).toISOString(),
|
||||
filter: parsedQuery.query,
|
||||
size: 0,
|
||||
searchAfterSortId: undefined,
|
||||
timeField: timeField ? timeField : '',
|
||||
track_total_hits: true,
|
||||
}),
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
const hits = rawResponse.hits;
|
||||
setTestQueryResult(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', {
|
||||
defaultMessage: 'Query matched {count} documents in the last {window}.',
|
||||
values: { count: hits.total, window },
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message;
|
||||
setTestQueryError(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', {
|
||||
defaultMessage: 'Error testing query: {message}',
|
||||
values: { message: message ? `${err.message}: ${message}` : err.message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{hasExpressionErrors ? (
|
||||
<Fragment>
|
||||
<EuiSpacer />
|
||||
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
) : null}
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.selectIndex"
|
||||
defaultMessage="Select an index"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<IndexSelectPopover
|
||||
index={index}
|
||||
data-test-subj="indexSelectPopover"
|
||||
esFields={esFields}
|
||||
timeField={timeField}
|
||||
errors={errors}
|
||||
onIndexChange={async (indices: string[]) => {
|
||||
setParam('index', indices);
|
||||
|
||||
// reset expression fields if indices are deleted
|
||||
if (indices.length === 0) {
|
||||
setAlertProperty('params', {
|
||||
...alertParams,
|
||||
index: indices,
|
||||
esQuery: DEFAULT_VALUES.QUERY,
|
||||
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
threshold: DEFAULT_VALUES.THRESHOLD,
|
||||
timeField: '',
|
||||
});
|
||||
} else {
|
||||
await refreshEsFields();
|
||||
}
|
||||
}}
|
||||
onTimeFieldChange={(updatedTimeField: string) => setParam('timeField', updatedTimeField)}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.queryPrompt"
|
||||
defaultMessage="Define the ES query"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFormRow
|
||||
id="queryEditor"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.queryPrompt.label"
|
||||
defaultMessage="ES query"
|
||||
/>
|
||||
}
|
||||
isInvalid={errors.esQuery.length > 0}
|
||||
error={errors.esQuery}
|
||||
helpText={
|
||||
<EuiLink
|
||||
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${docLinks.DOC_LINK_VERSION}/query-dsl.html`}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.queryPrompt.help"
|
||||
defaultMessage="ES Query DSL documentation"
|
||||
/>
|
||||
</EuiLink>
|
||||
}
|
||||
>
|
||||
<EuiCodeEditor
|
||||
mode={xJsonMode}
|
||||
width="100%"
|
||||
height="200px"
|
||||
theme="github"
|
||||
data-test-subj="queryJsonEditor"
|
||||
aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.queryEditor', {
|
||||
defaultMessage: 'ES query editor',
|
||||
})}
|
||||
value={xJson}
|
||||
onChange={(xjson: string) => {
|
||||
setXJson(xjson);
|
||||
setParam('esQuery', convertToJson(xjson));
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="testQuery"
|
||||
color={'primary'}
|
||||
iconSide={'left'}
|
||||
flush={'left'}
|
||||
iconType={'play'}
|
||||
disabled={hasValidationErrors()}
|
||||
onClick={onTestQuery}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.testQuery"
|
||||
defaultMessage="Test query"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFormRow>
|
||||
{testQueryResult && (
|
||||
<EuiFormRow>
|
||||
<EuiText data-test-subj="testQuerySuccess" color="subdued" size="s">
|
||||
<p>{testQueryResult}</p>
|
||||
</EuiText>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{testQueryError && (
|
||||
<EuiFormRow>
|
||||
<EuiText data-test-subj="testQueryError" color="danger" size="s">
|
||||
<p>{testQueryError}</p>
|
||||
</EuiText>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<EuiSpacer />
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.esQuery.ui.conditionPrompt"
|
||||
defaultMessage="When number of matches"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<ThresholdExpression
|
||||
data-test-subj="thresholdExpression"
|
||||
thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR}
|
||||
threshold={threshold ?? DEFAULT_VALUES.THRESHOLD}
|
||||
errors={errors}
|
||||
display="fullWidth"
|
||||
popupPosition={'upLeft'}
|
||||
onChangeSelectedThreshold={(selectedThresholds) =>
|
||||
setParam('threshold', selectedThresholds)
|
||||
}
|
||||
onChangeSelectedThresholdComparator={(selectedThresholdComparator) =>
|
||||
setParam('thresholdComparator', selectedThresholdComparator)
|
||||
}
|
||||
/>
|
||||
<ForLastExpression
|
||||
data-test-subj="forLastExpression"
|
||||
popupPosition={'upLeft'}
|
||||
timeWindowSize={timeWindowSize}
|
||||
timeWindowUnit={timeWindowUnit}
|
||||
display="fullWidth"
|
||||
errors={errors}
|
||||
onChangeWindowSize={(selectedWindowSize: number | undefined) =>
|
||||
setParam('timeWindowSize', selectedWindowSize)
|
||||
}
|
||||
onChangeWindowUnit={(selectedWindowUnit: string) =>
|
||||
setParam('timeWindowUnit', selectedWindowUnit)
|
||||
}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { EsQueryAlertTypeExpression as default };
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { lazy } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { validateExpression } from './validation';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
|
||||
|
||||
export function getAlertType(): AlertTypeModel<EsQueryAlertParams> {
|
||||
return {
|
||||
id: '.es-query',
|
||||
description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', {
|
||||
defaultMessage: 'Alert on matches against an ES query.',
|
||||
}),
|
||||
iconClass: 'logoElastic',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/alert-types.html#alert-type-es-query`;
|
||||
},
|
||||
alertParamsExpression: lazy(() => import('./expression')),
|
||||
validate: validateExpression,
|
||||
defaultActionMessage: i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.ui.alertType.defaultActionMessage',
|
||||
{
|
||||
defaultMessage: `ES query alert '\\{\\{alertName\\}\\}' is active:
|
||||
|
||||
- Value: \\{\\{context.value\\}\\}
|
||||
- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}
|
||||
- Timestamp: \\{\\{context.date\\}\\}`,
|
||||
}
|
||||
),
|
||||
requiresAppContext: false,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { AlertTypeParams } from '../../../../alerts/common';
|
||||
|
||||
export interface Comparator {
|
||||
text: string;
|
||||
value: string;
|
||||
requiredValues: number;
|
||||
}
|
||||
|
||||
export interface EsQueryAlertParams extends AlertTypeParams {
|
||||
index: string[];
|
||||
timeField?: string;
|
||||
esQuery: string;
|
||||
thresholdComparator?: string;
|
||||
threshold: number[];
|
||||
timeWindowSize: number;
|
||||
timeWindowUnit: string;
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 { EsQueryAlertParams } from './types';
|
||||
import { validateExpression } from './validation';
|
||||
|
||||
describe('expression params validation', () => {
|
||||
test('if index property is invalid should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: [],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.');
|
||||
});
|
||||
|
||||
test('if timeField property is not defined should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
|
||||
});
|
||||
|
||||
test('if esQuery property is invalid JSON should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.');
|
||||
});
|
||||
|
||||
test('if esQuery property is invalid should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`);
|
||||
});
|
||||
|
||||
test('if threshold0 property is not set should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
threshold: [],
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: '<',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.');
|
||||
});
|
||||
|
||||
test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
threshold: [1],
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: 'between',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.');
|
||||
});
|
||||
|
||||
test('if threshold0 property greater than threshold1 property should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
threshold: [10, 1],
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: 'between',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold1[0]).toBe(
|
||||
'Threshold 1 must be > Threshold 0.'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public';
|
||||
|
||||
export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => {
|
||||
const { index, timeField, esQuery, threshold, timeWindowSize, thresholdComparator } = alertParams;
|
||||
const validationResult = { errors: {} };
|
||||
const errors = {
|
||||
index: new Array<string>(),
|
||||
timeField: new Array<string>(),
|
||||
esQuery: new Array<string>(),
|
||||
threshold0: new Array<string>(),
|
||||
threshold1: new Array<string>(),
|
||||
thresholdComparator: new Array<string>(),
|
||||
timeWindowSize: new Array<string>(),
|
||||
};
|
||||
validationResult.errors = errors;
|
||||
if (!index || index.length === 0) {
|
||||
errors.index.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
|
||||
defaultMessage: 'Index is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!timeField) {
|
||||
errors.timeField.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
|
||||
defaultMessage: 'Time field is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!esQuery) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
|
||||
defaultMessage: 'ES query is required.',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const parsedQuery = JSON.parse(esQuery);
|
||||
if (!parsedQuery.query) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
|
||||
defaultMessage: `Query field is required.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
|
||||
defaultMessage: 'Query must be valid JSON.',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!threshold || threshold.length === 0 || threshold[0] === undefined) {
|
||||
errors.threshold0.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', {
|
||||
defaultMessage: 'Threshold 0 is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (
|
||||
thresholdComparator &&
|
||||
builtInComparators[thresholdComparator].requiredValues > 1 &&
|
||||
(!threshold ||
|
||||
threshold[1] === undefined ||
|
||||
(threshold && threshold.length < builtInComparators[thresholdComparator!].requiredValues))
|
||||
) {
|
||||
errors.threshold1.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold1Text', {
|
||||
defaultMessage: 'Threshold 1 is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (threshold && threshold.length === 2 && threshold[0] > threshold[1]) {
|
||||
errors.threshold1.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.greaterThenThreshold0Text', {
|
||||
defaultMessage: 'Threshold 1 must be > Threshold 0.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!timeWindowSize) {
|
||||
errors.timeWindowSize.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeWindowSizeText', {
|
||||
defaultMessage: 'Time window size is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
return validationResult;
|
||||
};
|
|
@ -7,6 +7,7 @@
|
|||
import { getAlertType as getGeoThresholdAlertType } from './geo_threshold';
|
||||
import { getAlertType as getGeoContainmentAlertType } from './geo_containment';
|
||||
import { getAlertType as getThresholdAlertType } from './threshold';
|
||||
import { getAlertType as getEsQueryAlertType } from './es_query';
|
||||
import { Config } from '../../common';
|
||||
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
|
||||
|
||||
|
@ -22,4 +23,5 @@ export function registerAlertTypes({
|
|||
alertTypeRegistry.register(getGeoContainmentAlertType());
|
||||
}
|
||||
alertTypeRegistry.register(getThresholdAlertType());
|
||||
alertTypeRegistry.register(getEsQueryAlertType());
|
||||
}
|
||||
|
|
|
@ -7,33 +7,13 @@
|
|||
import React, { useState, Fragment, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiExpression,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiSelect,
|
||||
EuiSpacer,
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiFormRow,
|
||||
EuiCallOut,
|
||||
EuiEmptyPrompt,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
import { EuiSpacer, EuiCallOut, EuiEmptyPrompt, EuiText, EuiTitle } from '@elastic/eui';
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
firstFieldOption,
|
||||
getIndexPatterns,
|
||||
getIndexOptions,
|
||||
getFields,
|
||||
COMPARATORS,
|
||||
builtInComparators,
|
||||
getTimeFieldOptions,
|
||||
OfExpression,
|
||||
ThresholdExpression,
|
||||
ForLastExpression,
|
||||
|
@ -45,6 +25,7 @@ import {
|
|||
import { ThresholdVisualization } from './visualization';
|
||||
import { IndexThresholdAlertParams } from './types';
|
||||
import './expression.scss';
|
||||
import { IndexSelectPopover } from '../components/index_select_popover';
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
AGGREGATION_TYPE: 'count',
|
||||
|
@ -101,12 +82,15 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
|
|||
const indexArray = indexParamToArray(index);
|
||||
const { http } = useKibana<KibanaDeps>().services;
|
||||
|
||||
const [indexPopoverOpen, setIndexPopoverOpen] = useState(false);
|
||||
const [indexPatterns, setIndexPatterns] = useState([]);
|
||||
const [esFields, setEsFields] = useState<unknown[]>([]);
|
||||
const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]);
|
||||
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
|
||||
const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false);
|
||||
const [esFields, setEsFields] = useState<
|
||||
Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
normalizedType: string;
|
||||
searchable: boolean;
|
||||
aggregatable: boolean;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const hasExpressionErrors = !!Object.keys(errors).find(
|
||||
(errorKey) =>
|
||||
|
@ -138,154 +122,23 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
|
|||
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
|
||||
});
|
||||
|
||||
if (indexArray.length > 0) {
|
||||
await refreshEsFields();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshEsFields = async () => {
|
||||
if (indexArray.length > 0) {
|
||||
const currentEsFields = await getFields(http, indexArray);
|
||||
const timeFields = getTimeFieldOptions(currentEsFields);
|
||||
|
||||
setEsFields(currentEsFields);
|
||||
setTimeFieldOptions([firstFieldOption, ...timeFields]);
|
||||
}
|
||||
};
|
||||
|
||||
const closeIndexPopover = () => {
|
||||
setIndexPopoverOpen(false);
|
||||
if (timeField === undefined) {
|
||||
setAlertParams('timeField', '');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const indexPatternsFunction = async () => {
|
||||
setIndexPatterns(await getIndexPatterns());
|
||||
};
|
||||
indexPatternsFunction();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultExpressionValues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const indexPopover = (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
id="indexSelectSearchBox"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel"
|
||||
defaultMessage="Indices to query"
|
||||
/>
|
||||
}
|
||||
isInvalid={errors.index.length > 0 && indexArray.length > 0}
|
||||
error={errors.index}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription"
|
||||
defaultMessage="Use * to broaden your query."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
async
|
||||
isLoading={isIndiciesLoading}
|
||||
isInvalid={errors.index.length > 0 && indexArray.length > 0}
|
||||
noSuggestions={!indexOptions.length}
|
||||
options={indexOptions}
|
||||
data-test-subj="thresholdIndexesComboBox"
|
||||
selectedOptions={indexArray.map((anIndex: string) => {
|
||||
return {
|
||||
label: anIndex,
|
||||
value: anIndex,
|
||||
};
|
||||
})}
|
||||
onChange={async (selected: EuiComboBoxOptionOption[]) => {
|
||||
const indicies: string[] = selected
|
||||
.map((aSelected) => aSelected.value)
|
||||
.filter<string>(isString);
|
||||
setAlertParams('index', indicies);
|
||||
const indices = selected.map((s) => s.value as string);
|
||||
|
||||
// reset time field and expression fields if indices are deleted
|
||||
if (indices.length === 0) {
|
||||
setTimeFieldOptions([firstFieldOption]);
|
||||
setAlertProperty('params', {
|
||||
...alertParams,
|
||||
index: indices,
|
||||
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
|
||||
termSize: DEFAULT_VALUES.TERM_SIZE,
|
||||
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
groupBy: DEFAULT_VALUES.GROUP_BY,
|
||||
threshold: DEFAULT_VALUES.THRESHOLD,
|
||||
timeField: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentEsFields = await getFields(http!, indices);
|
||||
const timeFields = getTimeFieldOptions(currentEsFields);
|
||||
|
||||
setEsFields(currentEsFields);
|
||||
setTimeFieldOptions([firstFieldOption, ...timeFields]);
|
||||
}}
|
||||
onSearchChange={async (search) => {
|
||||
setIsIndiciesLoading(true);
|
||||
setIndexOptions(await getIndexOptions(http!, search, indexPatterns));
|
||||
setIsIndiciesLoading(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!index) {
|
||||
setAlertParams('index', []);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
id="thresholdTimeField"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel"
|
||||
defaultMessage="Time field"
|
||||
/>
|
||||
}
|
||||
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||
error={errors.timeField}
|
||||
>
|
||||
<EuiSelect
|
||||
options={timeFieldOptions}
|
||||
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||
fullWidth
|
||||
name="thresholdTimeField"
|
||||
data-test-subj="thresholdAlertTimeFieldSelect"
|
||||
value={timeField || ''}
|
||||
onChange={(e) => {
|
||||
setAlertParams('timeField', e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (timeField === undefined) {
|
||||
setAlertParams('timeField', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
const renderIndices = (indices: string[]) => {
|
||||
const rows = indices.map((s: string, i: number) => {
|
||||
return (
|
||||
<p key={i}>
|
||||
{s}
|
||||
{i < indices.length - 1 ? ',' : null}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
return <div>{rows}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{hasExpressionErrors ? (
|
||||
|
@ -304,58 +157,36 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
|
|||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiPopover
|
||||
id="indexPopover"
|
||||
button={
|
||||
<EuiExpression
|
||||
display="columns"
|
||||
data-test-subj="selectIndexExpression"
|
||||
description={i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexLabel', {
|
||||
defaultMessage: 'index',
|
||||
})}
|
||||
value={indexArray.length > 0 ? renderIndices(indexArray) : firstFieldOption.text}
|
||||
isActive={indexPopoverOpen}
|
||||
onClick={() => {
|
||||
setIndexPopoverOpen(true);
|
||||
}}
|
||||
isInvalid={!(indexArray.length > 0 && timeField !== '')}
|
||||
/>
|
||||
}
|
||||
isOpen={indexPopoverOpen}
|
||||
closePopover={closeIndexPopover}
|
||||
ownFocus
|
||||
anchorPosition="downLeft"
|
||||
zIndex={8000}
|
||||
display="block"
|
||||
>
|
||||
<div style={{ width: '450px' }}>
|
||||
<EuiPopoverTitle>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
{i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel', {
|
||||
defaultMessage: 'index',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="closePopover"
|
||||
iconType="cross"
|
||||
color="danger"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel',
|
||||
{
|
||||
defaultMessage: 'Close',
|
||||
}
|
||||
)}
|
||||
onClick={closeIndexPopover}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopoverTitle>
|
||||
<IndexSelectPopover
|
||||
index={indexArray}
|
||||
esFields={esFields}
|
||||
timeField={timeField}
|
||||
errors={errors}
|
||||
onIndexChange={async (indices: string[]) => {
|
||||
setAlertParams('index', indices);
|
||||
|
||||
{indexPopover}
|
||||
</div>
|
||||
</EuiPopover>
|
||||
// reset expression fields if indices are deleted
|
||||
if (indices.length === 0) {
|
||||
setAlertProperty('params', {
|
||||
...alertParams,
|
||||
index: indices,
|
||||
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
|
||||
termSize: DEFAULT_VALUES.TERM_SIZE,
|
||||
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
groupBy: DEFAULT_VALUES.GROUP_BY,
|
||||
threshold: DEFAULT_VALUES.THRESHOLD,
|
||||
timeField: '',
|
||||
});
|
||||
} else {
|
||||
await refreshEsFields();
|
||||
}
|
||||
}}
|
||||
onTimeFieldChange={(updatedTimeField: string) =>
|
||||
setAlertParams('timeField', updatedTimeField)
|
||||
}
|
||||
/>
|
||||
<WhenExpression
|
||||
display="fullWidth"
|
||||
aggType={aggType ?? DEFAULT_VALUES.AGGREGATION_TYPE}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { EsQueryAlertActionContext, addMessages } from './action_context';
|
||||
import { EsQueryAlertParamsSchema } from './alert_type_params';
|
||||
|
||||
describe('ActionContext', () => {
|
||||
it('generates expected properties', async () => {
|
||||
const params = EsQueryAlertParamsSchema.validate({
|
||||
index: ['[index]'],
|
||||
timeField: '[timeField]',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
threshold: [4],
|
||||
});
|
||||
const base: EsQueryAlertActionContext = {
|
||||
date: '2020-01-01T00:00:00.000Z',
|
||||
value: 42,
|
||||
conditions: 'count greater than 4',
|
||||
hits: [],
|
||||
};
|
||||
const context = addMessages({ name: '[alert-name]' }, base, params);
|
||||
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
|
||||
expect(context.message).toEqual(
|
||||
`alert '[alert-name]' is active:
|
||||
|
||||
- Value: 42
|
||||
- Conditions Met: count greater than 4 over 5m
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z`
|
||||
);
|
||||
});
|
||||
|
||||
it('generates expected properties if comparator is between', async () => {
|
||||
const params = EsQueryAlertParamsSchema.validate({
|
||||
index: ['[index]'],
|
||||
timeField: '[timeField]',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: 'between',
|
||||
threshold: [4, 5],
|
||||
});
|
||||
const base: EsQueryAlertActionContext = {
|
||||
date: '2020-01-01T00:00:00.000Z',
|
||||
value: 4,
|
||||
conditions: 'count between 4 and 5',
|
||||
hits: [],
|
||||
};
|
||||
const context = addMessages({ name: '[alert-name]' }, base, params);
|
||||
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
|
||||
expect(context.message).toEqual(
|
||||
`alert '[alert-name]' is active:
|
||||
|
||||
- Value: 4
|
||||
- Conditions Met: count between 4 and 5 over 5m
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerts/server';
|
||||
import { EsQueryAlertParams } from './alert_type_params';
|
||||
import { ESSearchHit } from '../../../../../typings/elasticsearch';
|
||||
|
||||
// alert type context provided to actions
|
||||
|
||||
type AlertInfo = Pick<AlertExecutorOptions, 'name'>;
|
||||
|
||||
export interface ActionContext extends EsQueryAlertActionContext {
|
||||
// a short pre-constructed message which may be used in an action field
|
||||
title: string;
|
||||
// a longer pre-constructed message which may be used in an action field
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EsQueryAlertActionContext extends AlertInstanceContext {
|
||||
// the date the alert was run as an ISO date
|
||||
date: string;
|
||||
// the value that met the threshold
|
||||
value: number;
|
||||
// threshold conditions
|
||||
conditions: string;
|
||||
// query matches
|
||||
hits: ESSearchHit[];
|
||||
}
|
||||
|
||||
export function addMessages(
|
||||
alertInfo: AlertInfo,
|
||||
baseContext: EsQueryAlertActionContext,
|
||||
params: EsQueryAlertParams
|
||||
): ActionContext {
|
||||
const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', {
|
||||
defaultMessage: `alert '{name}' matched query`,
|
||||
values: {
|
||||
name: alertInfo.name,
|
||||
},
|
||||
});
|
||||
|
||||
const window = `${params.timeWindowSize}${params.timeWindowUnit}`;
|
||||
const message = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextMessageDescription', {
|
||||
defaultMessage: `alert '{name}' is active:
|
||||
|
||||
- Value: {value}
|
||||
- Conditions Met: {conditions} over {window}
|
||||
- Timestamp: {date}`,
|
||||
values: {
|
||||
name: alertInfo.name,
|
||||
value: baseContext.value,
|
||||
conditions: baseContext.conditions,
|
||||
window,
|
||||
date: baseContext.date,
|
||||
},
|
||||
});
|
||||
|
||||
return { ...baseContext, title, message };
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 type { Writable } from '@kbn/utility-types';
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import { getAlertType } from './alert_type';
|
||||
import { EsQueryAlertParams } from './alert_type_params';
|
||||
|
||||
describe('alertType', () => {
|
||||
const logger = loggingSystemMock.create().get();
|
||||
|
||||
const alertType = getAlertType(logger);
|
||||
|
||||
it('alert type creation structure is the expected value', async () => {
|
||||
expect(alertType.id).toBe('.es-query');
|
||||
expect(alertType.name).toBe('ES query');
|
||||
expect(alertType.actionGroups).toEqual([{ id: 'query matched', name: 'Query matched' }]);
|
||||
|
||||
expect(alertType.actionVariables).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"context": Array [
|
||||
Object {
|
||||
"description": "A message for the alert.",
|
||||
"name": "message",
|
||||
},
|
||||
Object {
|
||||
"description": "A title for the alert.",
|
||||
"name": "title",
|
||||
},
|
||||
Object {
|
||||
"description": "The date that the alert met the threshold condition.",
|
||||
"name": "date",
|
||||
},
|
||||
Object {
|
||||
"description": "The value that met the threshold condition.",
|
||||
"name": "value",
|
||||
},
|
||||
Object {
|
||||
"description": "The documents that met the threshold condition.",
|
||||
"name": "hits",
|
||||
},
|
||||
Object {
|
||||
"description": "A string that describes the threshold condition.",
|
||||
"name": "conditions",
|
||||
},
|
||||
],
|
||||
"params": Array [
|
||||
Object {
|
||||
"description": "The index the query was run against.",
|
||||
"name": "index",
|
||||
},
|
||||
Object {
|
||||
"description": "The string representation of the ES query.",
|
||||
"name": "esQuery",
|
||||
},
|
||||
Object {
|
||||
"description": "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
|
||||
"name": "threshold",
|
||||
},
|
||||
Object {
|
||||
"description": "A function to determine if the threshold has been met.",
|
||||
"name": "thresholdComparator",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('validator succeeds with valid params', async () => {
|
||||
const params: Partial<Writable<EsQueryAlertParams>> = {
|
||||
index: ['index-name'],
|
||||
timeField: 'time-field',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
expect(alertType.validate?.params?.validate(params)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('validator fails with invalid params - threshold', async () => {
|
||||
const paramsSchema = alertType.validate?.params;
|
||||
if (!paramsSchema) throw new Error('params validator not set');
|
||||
|
||||
const params: Partial<Writable<EsQueryAlertParams>> = {
|
||||
index: ['index-name'],
|
||||
timeField: 'time-field',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: 'between',
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: must have two elements for the \\"between\\" comparator"`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,307 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { Logger } from 'src/core/server';
|
||||
import { ESSearchResponse } from '../../../../../typings/elasticsearch';
|
||||
import { AlertType, AlertExecutorOptions } from '../../types';
|
||||
import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context';
|
||||
import {
|
||||
EsQueryAlertParams,
|
||||
EsQueryAlertParamsSchema,
|
||||
EsQueryAlertState,
|
||||
} from './alert_type_params';
|
||||
import { STACK_ALERTS_FEATURE_ID } from '../../../common';
|
||||
import { ComparatorFns, getHumanReadableComparator } from '../lib';
|
||||
import { parseDuration } from '../../../../alerts/server';
|
||||
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
|
||||
import { ESSearchHit } from '../../../../../typings/elasticsearch';
|
||||
|
||||
export const ES_QUERY_ID = '.es-query';
|
||||
|
||||
const DEFAULT_MAX_HITS_PER_EXECUTION = 1000;
|
||||
|
||||
const ActionGroupId = 'query matched';
|
||||
const ConditionMetAlertInstanceId = 'query matched';
|
||||
|
||||
export function getAlertType(
|
||||
logger: Logger
|
||||
): AlertType<EsQueryAlertParams, EsQueryAlertState, {}, ActionContext, typeof ActionGroupId> {
|
||||
const alertTypeName = i18n.translate('xpack.stackAlerts.esQuery.alertTypeTitle', {
|
||||
defaultMessage: 'ES query',
|
||||
});
|
||||
|
||||
const actionGroupName = i18n.translate('xpack.stackAlerts.esQuery.actionGroupThresholdMetTitle', {
|
||||
defaultMessage: 'Query matched',
|
||||
});
|
||||
|
||||
const actionVariableContextDateLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextDateLabel',
|
||||
{
|
||||
defaultMessage: 'The date that the alert met the threshold condition.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextValueLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextValueLabel',
|
||||
{
|
||||
defaultMessage: 'The value that met the threshold condition.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextHitsLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextHitsLabel',
|
||||
{
|
||||
defaultMessage: 'The documents that met the threshold condition.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextMessageLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextMessageLabel',
|
||||
{
|
||||
defaultMessage: 'A message for the alert.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextTitleLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextTitleLabel',
|
||||
{
|
||||
defaultMessage: 'A title for the alert.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextIndexLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextIndexLabel',
|
||||
{
|
||||
defaultMessage: 'The index the query was run against.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextQueryLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextQueryLabel',
|
||||
{
|
||||
defaultMessage: 'The string representation of the ES query.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextThresholdLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel',
|
||||
{
|
||||
defaultMessage:
|
||||
"An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextThresholdComparatorLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel',
|
||||
{
|
||||
defaultMessage: 'A function to determine if the threshold has been met.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextConditionsLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextConditionsLabel',
|
||||
{
|
||||
defaultMessage: 'A string that describes the threshold condition.',
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: ES_QUERY_ID,
|
||||
name: alertTypeName,
|
||||
actionGroups: [{ id: ActionGroupId, name: actionGroupName }],
|
||||
defaultActionGroupId: ActionGroupId,
|
||||
validate: {
|
||||
params: EsQueryAlertParamsSchema,
|
||||
},
|
||||
actionVariables: {
|
||||
context: [
|
||||
{ name: 'message', description: actionVariableContextMessageLabel },
|
||||
{ name: 'title', description: actionVariableContextTitleLabel },
|
||||
{ name: 'date', description: actionVariableContextDateLabel },
|
||||
{ name: 'value', description: actionVariableContextValueLabel },
|
||||
{ name: 'hits', description: actionVariableContextHitsLabel },
|
||||
{ name: 'conditions', description: actionVariableContextConditionsLabel },
|
||||
],
|
||||
params: [
|
||||
{ name: 'index', description: actionVariableContextIndexLabel },
|
||||
{ name: 'esQuery', description: actionVariableContextQueryLabel },
|
||||
{ name: 'threshold', description: actionVariableContextThresholdLabel },
|
||||
{ name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel },
|
||||
],
|
||||
},
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
producer: STACK_ALERTS_FEATURE_ID,
|
||||
};
|
||||
|
||||
async function executor(
|
||||
options: AlertExecutorOptions<
|
||||
EsQueryAlertParams,
|
||||
EsQueryAlertState,
|
||||
{},
|
||||
ActionContext,
|
||||
typeof ActionGroupId
|
||||
>
|
||||
) {
|
||||
const { alertId, name, services, params, state } = options;
|
||||
const previousTimestamp = state.latestTimestamp;
|
||||
|
||||
const callCluster = services.callCluster;
|
||||
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
|
||||
|
||||
const compareFn = ComparatorFns.get(params.thresholdComparator);
|
||||
if (compareFn == null) {
|
||||
throw new Error(getInvalidComparatorError(params.thresholdComparator));
|
||||
}
|
||||
|
||||
// During each alert execution, we run the configured query, get a hit count
|
||||
// (hits.total) and retrieve up to DEFAULT_MAX_HITS_PER_EXECUTION hits. We
|
||||
// evaluate the threshold condition using the value of hits.total. If the threshold
|
||||
// condition is met, the hits are counted toward the query match and we update
|
||||
// the alert state with the timestamp of the latest hit. In the next execution
|
||||
// of the alert, the latestTimestamp will be used to gate the query in order to
|
||||
// avoid counting a document multiple times.
|
||||
|
||||
let timestamp: string | undefined = previousTimestamp;
|
||||
const filter = timestamp
|
||||
? {
|
||||
bool: {
|
||||
filter: [
|
||||
parsedQuery.query,
|
||||
{
|
||||
bool: {
|
||||
must_not: [
|
||||
{ bool: { filter: [{ range: { [params.timeField]: { lte: timestamp } } }] } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: parsedQuery.query;
|
||||
|
||||
const query = buildSortedEventsQuery({
|
||||
index: params.index,
|
||||
from: dateStart,
|
||||
to: dateEnd,
|
||||
filter,
|
||||
size: DEFAULT_MAX_HITS_PER_EXECUTION,
|
||||
sortOrder: 'desc',
|
||||
searchAfterSortId: undefined,
|
||||
timeField: params.timeField,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`);
|
||||
|
||||
const searchResult: ESSearchResponse<unknown, {}> = await callCluster('search', query);
|
||||
|
||||
if (searchResult.hits.hits.length > 0) {
|
||||
const numMatches = searchResult.hits.total.value;
|
||||
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query has ${numMatches} matches`);
|
||||
|
||||
// apply the alert condition
|
||||
const conditionMet = compareFn(numMatches, params.threshold);
|
||||
|
||||
if (conditionMet) {
|
||||
const humanFn = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription',
|
||||
{
|
||||
defaultMessage: `Number of matching documents is {thresholdComparator} {threshold}`,
|
||||
values: {
|
||||
thresholdComparator: getHumanReadableComparator(params.thresholdComparator),
|
||||
threshold: params.threshold.join(' and '),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const baseContext: EsQueryAlertActionContext = {
|
||||
date: new Date().toISOString(),
|
||||
value: numMatches,
|
||||
conditions: humanFn,
|
||||
hits: searchResult.hits.hits,
|
||||
};
|
||||
|
||||
const actionContext = addMessages(options, baseContext, params);
|
||||
const alertInstance = options.services.alertInstanceFactory(ConditionMetAlertInstanceId);
|
||||
alertInstance
|
||||
// store the params we would need to recreate the query that led to this alert instance
|
||||
.replaceState({ latestTimestamp: timestamp, dateStart, dateEnd })
|
||||
.scheduleActions(ActionGroupId, actionContext);
|
||||
|
||||
// update the timestamp based on the current search results
|
||||
const firstHitWithSort = searchResult.hits.hits.find(
|
||||
(hit: ESSearchHit) => hit.sort != null
|
||||
);
|
||||
const lastTimestamp = firstHitWithSort?.sort;
|
||||
if (lastTimestamp != null && lastTimestamp.length > 0) {
|
||||
timestamp = lastTimestamp[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latestTimestamp: timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getInvalidComparatorError(comparator: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidWindowSizeError(windowValue: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
|
||||
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
|
||||
values: {
|
||||
windowValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidQueryError(query: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
|
||||
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
|
||||
values: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchParams(queryParams: EsQueryAlertParams) {
|
||||
const date = Date.now();
|
||||
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
|
||||
|
||||
let parsedQuery;
|
||||
try {
|
||||
parsedQuery = JSON.parse(esQuery);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
if (parsedQuery && !parsedQuery.query) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||
let timeWindow: number;
|
||||
try {
|
||||
timeWindow = parseDuration(window);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidWindowSizeError(window));
|
||||
}
|
||||
|
||||
const dateStart = new Date(date - timeWindow).toISOString();
|
||||
const dateEnd = new Date(date).toISOString();
|
||||
|
||||
return { parsedQuery, dateStart, dateEnd };
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* 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 { TypeOf } from '@kbn/config-schema';
|
||||
import type { Writable } from '@kbn/utility-types';
|
||||
import { EsQueryAlertParamsSchema, EsQueryAlertParams } from './alert_type_params';
|
||||
|
||||
const DefaultParams: Writable<Partial<EsQueryAlertParams>> = {
|
||||
index: ['index-name'],
|
||||
timeField: 'time-field',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
describe('alertType Params validate()', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let params: any;
|
||||
beforeEach(() => {
|
||||
params = { ...DefaultParams };
|
||||
});
|
||||
|
||||
it('passes for valid input', async () => {
|
||||
expect(validate()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails for invalid index', async () => {
|
||||
delete params.index;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[index]: expected value of type [array] but got [undefined]"`
|
||||
);
|
||||
|
||||
params.index = 42;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[index]: expected value of type [array] but got [number]"`
|
||||
);
|
||||
|
||||
params.index = 'index-name';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[index]: could not parse array value from json input"`
|
||||
);
|
||||
|
||||
params.index = [];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[index]: array size is [0], but cannot be smaller than [1]"`
|
||||
);
|
||||
|
||||
params.index = ['', 'a'];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[index.0]: value has length [0] but it must have a minimum length of [1]."`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid timeField', async () => {
|
||||
delete params.timeField;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeField]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
|
||||
params.timeField = 42;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeField]: expected value of type [string] but got [number]"`
|
||||
);
|
||||
|
||||
params.timeField = '';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeField]: value has length [0] but it must have a minimum length of [1]."`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid esQuery', async () => {
|
||||
delete params.esQuery;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[esQuery]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
|
||||
params.esQuery = 42;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[esQuery]: expected value of type [string] but got [number]"`
|
||||
);
|
||||
|
||||
params.esQuery = '';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[esQuery]: value has length [0] but it must have a minimum length of [1]."`
|
||||
);
|
||||
|
||||
params.esQuery = '{\n "query":{\n "match_all" : {}\n }\n';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[esQuery]: must be valid JSON"`);
|
||||
|
||||
params.esQuery = '{\n "aggs":{\n "match_all" : {}\n }\n}';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[esQuery]: must contain \\"query\\""`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid timeWindowSize', async () => {
|
||||
delete params.timeWindowSize;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowSize]: expected value of type [number] but got [undefined]"`
|
||||
);
|
||||
|
||||
params.timeWindowSize = 'foo';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowSize]: expected value of type [number] but got [string]"`
|
||||
);
|
||||
|
||||
params.timeWindowSize = 0;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowSize]: Value must be equal to or greater than [1]."`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid timeWindowUnit', async () => {
|
||||
delete params.timeWindowUnit;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowUnit]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
|
||||
params.timeWindowUnit = 42;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowUnit]: expected value of type [string] but got [number]"`
|
||||
);
|
||||
|
||||
params.timeWindowUnit = 'x';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid threshold', async () => {
|
||||
params.threshold = 42;
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: expected value of type [array] but got [number]"`
|
||||
);
|
||||
|
||||
params.threshold = 'x';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: could not parse array value from json input"`
|
||||
);
|
||||
|
||||
params.threshold = [];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: array size is [0], but cannot be smaller than [1]"`
|
||||
);
|
||||
|
||||
params.threshold = [1, 2, 3];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: array size is [3], but cannot be greater than [2]"`
|
||||
);
|
||||
|
||||
params.threshold = ['foo'];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold.0]: expected value of type [number] but got [string]"`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid thresholdComparator', async () => {
|
||||
params.thresholdComparator = '[invalid-comparator]';
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails for invalid threshold length', async () => {
|
||||
params.thresholdComparator = '<';
|
||||
params.threshold = [0, 1, 2];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: array size is [3], but cannot be greater than [2]"`
|
||||
);
|
||||
|
||||
params.thresholdComparator = 'between';
|
||||
params.threshold = [0];
|
||||
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[threshold]: must have two elements for the \\"between\\" comparator"`
|
||||
);
|
||||
});
|
||||
|
||||
function onValidate(): () => void {
|
||||
return () => validate();
|
||||
}
|
||||
|
||||
function validate(): TypeOf<typeof EsQueryAlertParamsSchema> {
|
||||
return EsQueryAlertParamsSchema.validate(params);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server';
|
||||
import { AlertTypeState } from '../../../../alerts/server';
|
||||
|
||||
// alert type parameters
|
||||
export type EsQueryAlertParams = TypeOf<typeof EsQueryAlertParamsSchema>;
|
||||
export interface EsQueryAlertState extends AlertTypeState {
|
||||
latestTimestamp: string | undefined;
|
||||
}
|
||||
|
||||
export const EsQueryAlertParamsSchemaProperties = {
|
||||
index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||
timeField: schema.string({ minLength: 1 }),
|
||||
esQuery: schema.string({ minLength: 1 }),
|
||||
timeWindowSize: schema.number({ min: 1 }),
|
||||
timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }),
|
||||
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
|
||||
thresholdComparator: schema.string({ validate: validateComparator }),
|
||||
};
|
||||
|
||||
export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, {
|
||||
validate: validateParams,
|
||||
});
|
||||
|
||||
const betweenComparators = new Set(['between', 'notBetween']);
|
||||
|
||||
// using direct type not allowed, circular reference, so body is typed to any
|
||||
function validateParams(anyParams: unknown): string | undefined {
|
||||
const {
|
||||
esQuery,
|
||||
thresholdComparator,
|
||||
threshold,
|
||||
}: EsQueryAlertParams = anyParams as EsQueryAlertParams;
|
||||
|
||||
if (betweenComparators.has(thresholdComparator) && threshold.length === 1) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', {
|
||||
defaultMessage:
|
||||
'[threshold]: must have two elements for the "{thresholdComparator}" comparator',
|
||||
values: {
|
||||
thresholdComparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedQuery = JSON.parse(esQuery);
|
||||
|
||||
if (parsedQuery && !parsedQuery.query) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.missingEsQueryErrorMessage', {
|
||||
defaultMessage: '[esQuery]: must contain "query"',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidEsQueryErrorMessage', {
|
||||
defaultMessage: '[esQuery]: must be valid JSON',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator)) return;
|
||||
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { Logger } from 'src/core/server';
|
||||
import { AlertingSetup } from '../../types';
|
||||
import { getAlertType } from './alert_type';
|
||||
|
||||
interface RegisterParams {
|
||||
logger: Logger;
|
||||
alerts: AlertingSetup;
|
||||
}
|
||||
|
||||
export function register(params: RegisterParams) {
|
||||
const { logger, alerts } = params;
|
||||
alerts.registerType(getAlertType(logger));
|
||||
}
|
|
@ -9,7 +9,7 @@ import { AlertingSetup, StackAlertsStartDeps } from '../types';
|
|||
import { register as registerIndexThreshold } from './index_threshold';
|
||||
import { register as registerGeoThreshold } from './geo_threshold';
|
||||
import { register as registerGeoContainment } from './geo_containment';
|
||||
|
||||
import { register as registerEsQuery } from './es_query';
|
||||
interface RegisterAlertTypesParams {
|
||||
logger: Logger;
|
||||
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
|
||||
|
@ -20,4 +20,5 @@ export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) {
|
|||
registerIndexThreshold(params);
|
||||
registerGeoThreshold(params);
|
||||
registerGeoContainment(params);
|
||||
registerEsQuery(params);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ The index threshold alert type is designed to run an ES query over indices,
|
|||
aggregating field values from documents, comparing them to threshold values,
|
||||
and scheduling actions to run when the thresholds are met.
|
||||
|
||||
And example would be checking a monitoring index for percent cpu usage field
|
||||
An example would be checking a monitoring index for percent cpu usage field
|
||||
values that are greater than some threshold, which could then be used to invoke
|
||||
an action (email, slack, etc) to notify interested parties when the threshold
|
||||
is exceeded.
|
||||
|
|
|
@ -14,30 +14,10 @@ import {
|
|||
CoreQueryParamsSchemaProperties,
|
||||
TimeSeriesQuery,
|
||||
} from '../../../../triggers_actions_ui/server';
|
||||
import { ComparatorFns, getHumanReadableComparator } from '../lib';
|
||||
|
||||
export const ID = '.index-threshold';
|
||||
|
||||
enum Comparator {
|
||||
GT = '>',
|
||||
LT = '<',
|
||||
GT_OR_EQ = '>=',
|
||||
LT_OR_EQ = '<=',
|
||||
BETWEEN = 'between',
|
||||
NOT_BETWEEN = 'notBetween',
|
||||
}
|
||||
|
||||
const humanReadableComparators = new Map<string, string>([
|
||||
[Comparator.LT, 'less than'],
|
||||
[Comparator.LT_OR_EQ, 'less than or equal to'],
|
||||
[Comparator.GT_OR_EQ, 'greater than or equal to'],
|
||||
[Comparator.GT, 'greater than'],
|
||||
[Comparator.BETWEEN, 'between'],
|
||||
[Comparator.NOT_BETWEEN, 'not between'],
|
||||
]);
|
||||
|
||||
const ActionGroupId = 'threshold met';
|
||||
const ComparatorFns = getComparatorFns();
|
||||
export const ComparatorFnNames = new Set(ComparatorFns.keys());
|
||||
|
||||
export function getAlertType(
|
||||
logger: Logger,
|
||||
|
@ -155,7 +135,14 @@ export function getAlertType(
|
|||
|
||||
const compareFn = ComparatorFns.get(params.thresholdComparator);
|
||||
if (compareFn == null) {
|
||||
throw new Error(getInvalidComparatorMessage(params.thresholdComparator));
|
||||
throw new Error(
|
||||
i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator: params.thresholdComparator,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const callCluster = services.callCluster;
|
||||
|
@ -210,40 +197,3 @@ export function getAlertType(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getInvalidComparatorMessage(comparator: string) {
|
||||
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ComparatorFn = (value: number, threshold: number[]) => boolean;
|
||||
|
||||
function getComparatorFns(): Map<string, ComparatorFn> {
|
||||
const fns: Record<string, ComparatorFn> = {
|
||||
[Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0],
|
||||
[Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0],
|
||||
[Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0],
|
||||
[Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0],
|
||||
[Comparator.BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value >= threshold[0] && value <= threshold[1],
|
||||
[Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value < threshold[0] || value > threshold[1],
|
||||
};
|
||||
|
||||
const result = new Map<string, ComparatorFn>();
|
||||
for (const key of Object.keys(fns)) {
|
||||
result.set(key, fns[key]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getHumanReadableComparator(comparator: string) {
|
||||
return humanReadableComparators.has(comparator)
|
||||
? humanReadableComparators.get(comparator)
|
||||
: comparator;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
import {
|
||||
CoreQueryParamsSchemaProperties,
|
||||
validateCoreQueryBody,
|
||||
|
@ -54,5 +54,10 @@ function validateParams(anyParams: unknown): string | undefined {
|
|||
export function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator)) return;
|
||||
|
||||
return getInvalidComparatorMessage(comparator);
|
||||
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
enum Comparator {
|
||||
GT = '>',
|
||||
LT = '<',
|
||||
GT_OR_EQ = '>=',
|
||||
LT_OR_EQ = '<=',
|
||||
BETWEEN = 'between',
|
||||
NOT_BETWEEN = 'notBetween',
|
||||
}
|
||||
|
||||
const humanReadableComparators = new Map<string, string>([
|
||||
[Comparator.LT, 'less than'],
|
||||
[Comparator.LT_OR_EQ, 'less than or equal to'],
|
||||
[Comparator.GT_OR_EQ, 'greater than or equal to'],
|
||||
[Comparator.GT, 'greater than'],
|
||||
[Comparator.BETWEEN, 'between'],
|
||||
[Comparator.NOT_BETWEEN, 'not between'],
|
||||
]);
|
||||
|
||||
export const ComparatorFns = getComparatorFns();
|
||||
export const ComparatorFnNames = new Set(ComparatorFns.keys());
|
||||
|
||||
type ComparatorFn = (value: number, threshold: number[]) => boolean;
|
||||
|
||||
function getComparatorFns(): Map<string, ComparatorFn> {
|
||||
const fns: Record<string, ComparatorFn> = {
|
||||
[Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0],
|
||||
[Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0],
|
||||
[Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0],
|
||||
[Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0],
|
||||
[Comparator.BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value >= threshold[0] && value <= threshold[1],
|
||||
[Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value < threshold[0] || value > threshold[1],
|
||||
};
|
||||
|
||||
const result = new Map<string, ComparatorFn>();
|
||||
for (const key of Object.keys(fns)) {
|
||||
result.set(key, fns[key]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getHumanReadableComparator(comparator: string) {
|
||||
return humanReadableComparators.has(comparator)
|
||||
? humanReadableComparators.get(comparator)
|
||||
: comparator;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types';
|
|
@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => {
|
|||
const featuresSetup = featuresPluginMock.createSetup();
|
||||
await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup });
|
||||
|
||||
expect(alertingSetup.registerType).toHaveBeenCalledTimes(3);
|
||||
expect(alertingSetup.registerType).toHaveBeenCalledTimes(4);
|
||||
|
||||
const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0];
|
||||
const testedIndexThresholdArgs = {
|
||||
|
@ -67,6 +67,25 @@ describe('AlertingBuiltins Plugin', () => {
|
|||
}
|
||||
`);
|
||||
|
||||
const esQueryArgs = alertingSetup.registerType.mock.calls[3][0];
|
||||
const testedEsQueryArgs = {
|
||||
id: esQueryArgs.id,
|
||||
name: esQueryArgs.name,
|
||||
actionGroups: esQueryArgs.actionGroups,
|
||||
};
|
||||
expect(testedEsQueryArgs).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"actionGroups": Array [
|
||||
Object {
|
||||
"id": "query matched",
|
||||
"name": "Query matched",
|
||||
},
|
||||
],
|
||||
"id": ".es-query",
|
||||
"name": "ES query",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20872,13 +20872,7 @@
|
|||
"xpack.stackAlerts.indexThreshold.alertTypeTitle": "インデックスしきい値",
|
||||
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効な thresholdComparator が指定されました:{comparator}",
|
||||
"xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]: 「{thresholdComparator}」比較子の場合には2つの要素が必要です",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "閉じる",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "* で検索クエリの範囲を広げます。",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "インデックス",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "インデックス",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "クエリを実行するインデックス",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "時間フィールド",
|
||||
"xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "アラート '\\{\\{alertName\\}\\}' はグループ '\\{\\{context.group\\}\\}' でアクティブです:\n\n- 値:\\{\\{context.value\\}\\}\n- 満たされた条件:\\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- タイムスタンプ:\\{\\{context.date\\}\\}",
|
||||
"xpack.stackAlerts.threshold.ui.alertType.descriptionText": "アグリゲーションされたクエリがしきい値に達したときにアラートを発行します。",
|
||||
"xpack.stackAlerts.threshold.ui.conditionPrompt": "条件を定義してください",
|
||||
|
|
|
@ -20920,13 +20920,7 @@
|
|||
"xpack.stackAlerts.indexThreshold.alertTypeTitle": "索引阈值",
|
||||
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}",
|
||||
"xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "关闭",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "使用 * 可扩大您的查询范围。",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "索引",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "索引",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "要查询的索引",
|
||||
"xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "时间字段",
|
||||
"xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "组“\\{\\{context.group\\}\\}”的告警“\\{\\{alertName\\}\\}”处于活动状态:\n\n- 值:\\{\\{context.value\\}\\}\n- 满足的条件:\\{\\{context.conditions\\}\\} 超过 \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- 时间戳:\\{\\{context.date\\}\\}",
|
||||
"xpack.stackAlerts.threshold.ui.alertType.descriptionText": "聚合查询达到阈值时告警。",
|
||||
"xpack.stackAlerts.threshold.ui.conditionPrompt": "定义条件",
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
CoreQueryParams,
|
||||
CoreQueryParamsSchemaProperties,
|
||||
validateCoreQueryBody,
|
||||
validateTimeWindowUnits,
|
||||
} from './lib';
|
||||
|
||||
// future enhancement: make these configurable?
|
||||
|
|
|
@ -9,4 +9,5 @@ export {
|
|||
CoreQueryParams,
|
||||
CoreQueryParamsSchemaProperties,
|
||||
validateCoreQueryBody,
|
||||
validateTimeWindowUnits,
|
||||
} from './core_query_types';
|
||||
|
|
|
@ -14,6 +14,7 @@ export {
|
|||
CoreQueryParams,
|
||||
CoreQueryParamsSchemaProperties,
|
||||
validateCoreQueryBody,
|
||||
validateTimeWindowUnits,
|
||||
MAX_INTERVALS,
|
||||
MAX_GROUPS,
|
||||
DEFAULT_GROUPS,
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
|
||||
import { Spaces } from '../../../../scenarios';
|
||||
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
|
||||
import {
|
||||
ESTestIndexTool,
|
||||
ES_TEST_INDEX_NAME,
|
||||
getUrlPrefix,
|
||||
ObjectRemover,
|
||||
} from '../../../../../common/lib';
|
||||
import { createEsDocuments } from './create_test_data';
|
||||
|
||||
const ALERT_TYPE_ID = '.es-query';
|
||||
const ACTION_TYPE_ID = '.index';
|
||||
const ES_TEST_INDEX_SOURCE = 'builtin-alert:es-query';
|
||||
const ES_TEST_INDEX_REFERENCE = '-na-';
|
||||
const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`;
|
||||
|
||||
const ALERT_INTERVALS_TO_WRITE = 5;
|
||||
const ALERT_INTERVAL_SECONDS = 3;
|
||||
const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000;
|
||||
const ES_GROUPS_TO_WRITE = 3;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function alertTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const retry = getService('retry');
|
||||
const es = getService('legacyEs');
|
||||
const esTestIndexTool = new ESTestIndexTool(es, retry);
|
||||
const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME);
|
||||
|
||||
describe('alert', async () => {
|
||||
let endDate: string;
|
||||
let actionId: string;
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
|
||||
beforeEach(async () => {
|
||||
await esTestIndexTool.destroy();
|
||||
await esTestIndexTool.setup();
|
||||
|
||||
await esTestIndexToolOutput.destroy();
|
||||
await esTestIndexToolOutput.setup();
|
||||
|
||||
actionId = await createAction(supertest, objectRemover);
|
||||
|
||||
// write documents in the future, figure out the end date
|
||||
const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS;
|
||||
endDate = new Date(endDateMillis).toISOString();
|
||||
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await objectRemover.removeAll();
|
||||
await esTestIndexTool.destroy();
|
||||
await esTestIndexToolOutput.destroy();
|
||||
});
|
||||
|
||||
it('runs correctly: threshold on hit count < >', async () => {
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
});
|
||||
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
});
|
||||
|
||||
const docs = await waitForDocs(2);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern = /alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('runs correctly with query: threshold on hit count < >', async () => {
|
||||
const rangeQuery = (rangeThreshold: number) => {
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
testedValue: {
|
||||
gte: rangeThreshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
|
||||
thresholdComparator: '>=',
|
||||
threshold: [0],
|
||||
});
|
||||
|
||||
await createAlert({
|
||||
name: 'fires once',
|
||||
esQuery: JSON.stringify(
|
||||
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
|
||||
),
|
||||
thresholdComparator: '>=',
|
||||
threshold: [0],
|
||||
});
|
||||
|
||||
const docs = await waitForDocs(1);
|
||||
for (const doc of docs) {
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('fires once');
|
||||
expect(title).to.be(`alert 'fires once' matched query`);
|
||||
const messagePattern = /alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
}
|
||||
});
|
||||
|
||||
async function createEsDocumentsInGroups(groups: number) {
|
||||
await createEsDocuments(
|
||||
es,
|
||||
esTestIndexTool,
|
||||
endDate,
|
||||
ALERT_INTERVALS_TO_WRITE,
|
||||
ALERT_INTERVAL_MILLIS,
|
||||
groups
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDocs(count: number): Promise<any[]> {
|
||||
return await esTestIndexToolOutput.waitForDocs(
|
||||
ES_TEST_INDEX_SOURCE,
|
||||
ES_TEST_INDEX_REFERENCE,
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateAlertParams {
|
||||
name: string;
|
||||
timeField?: string;
|
||||
esQuery: string;
|
||||
thresholdComparator: string;
|
||||
threshold: number[];
|
||||
timeWindowSize?: number;
|
||||
}
|
||||
|
||||
async function createAlert(params: CreateAlertParams): Promise<string> {
|
||||
const action = {
|
||||
id: actionId,
|
||||
group: 'query matched',
|
||||
params: {
|
||||
documents: [
|
||||
{
|
||||
source: ES_TEST_INDEX_SOURCE,
|
||||
reference: ES_TEST_INDEX_REFERENCE,
|
||||
params: {
|
||||
name: '{{{alertName}}}',
|
||||
value: '{{{context.value}}}',
|
||||
title: '{{{context.title}}}',
|
||||
message: '{{{context.message}}}',
|
||||
},
|
||||
hits: '{{context.hits}}',
|
||||
date: '{{{context.date}}}',
|
||||
previousTimestamp: '{{{state.latestTimestamp}}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { body: createdAlert } = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: params.name,
|
||||
consumer: 'alerts',
|
||||
enabled: true,
|
||||
alertTypeId: ALERT_TYPE_ID,
|
||||
schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` },
|
||||
actions: [action],
|
||||
params: {
|
||||
index: [ES_TEST_INDEX_NAME],
|
||||
timeField: params.timeField || 'date',
|
||||
esQuery: params.esQuery,
|
||||
timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: params.thresholdComparator,
|
||||
threshold: params.threshold,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const alertId = createdAlert.id;
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
return alertId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createAction(supertest: any, objectRemover: ObjectRemover): Promise<string> {
|
||||
const { body: createdAction } = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'index action for es query FT',
|
||||
actionTypeId: ACTION_TYPE_ID,
|
||||
config: {
|
||||
index: ES_TEST_OUTPUT_INDEX_NAME,
|
||||
},
|
||||
secrets: {},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const actionId = createdAction.id;
|
||||
objectRemover.add(Spaces.space1.id, actionId, 'action', 'actions');
|
||||
|
||||
return actionId;
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { times } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib';
|
||||
|
||||
// default end date
|
||||
export const END_DATE = '2020-01-01T00:00:00Z';
|
||||
|
||||
export const DOCUMENT_SOURCE = 'queryDataEndpointTests';
|
||||
export const DOCUMENT_REFERENCE = '-na-';
|
||||
|
||||
export async function createEsDocuments(
|
||||
es: any,
|
||||
esTestIndexTool: ESTestIndexTool,
|
||||
endDate: string = END_DATE,
|
||||
intervals: number = 1,
|
||||
intervalMillis: number = 1000,
|
||||
groups: number = 2
|
||||
) {
|
||||
const endDateMillis = Date.parse(endDate) - intervalMillis / 2;
|
||||
|
||||
let testedValue = 0;
|
||||
times(intervals, (interval) => {
|
||||
const date = endDateMillis - interval * intervalMillis;
|
||||
|
||||
// don't need await on these, wait at the end of the function
|
||||
times(groups, () => {
|
||||
createEsDocument(es, date, testedValue++);
|
||||
});
|
||||
});
|
||||
|
||||
const totalDocuments = intervals * groups;
|
||||
await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments);
|
||||
}
|
||||
|
||||
async function createEsDocument(es: any, epochMillis: number, testedValue: number) {
|
||||
const document = {
|
||||
source: DOCUMENT_SOURCE,
|
||||
reference: DOCUMENT_REFERENCE,
|
||||
date: new Date(epochMillis).toISOString(),
|
||||
date_epoch_millis: epochMillis,
|
||||
testedValue,
|
||||
};
|
||||
|
||||
const response = await es.index({
|
||||
id: uuid(),
|
||||
index: ES_TEST_INDEX_NAME,
|
||||
body: document,
|
||||
});
|
||||
|
||||
if (response.result !== 'created') {
|
||||
throw new Error(`document not created: ${JSON.stringify(response)}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('es_query', () => {
|
||||
loadTestFile(require.resolve('./alert'));
|
||||
});
|
||||
}
|
|
@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
|||
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('builtin alertTypes', () => {
|
||||
loadTestFile(require.resolve('./index_threshold'));
|
||||
loadTestFile(require.resolve('./es_query'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -29,10 +29,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
});
|
||||
}
|
||||
|
||||
async function defineAlert(alertName: string) {
|
||||
async function defineAlert(alertName: string, alertType?: string) {
|
||||
alertType = alertType || '.index-threshold';
|
||||
await pageObjects.triggersActionsUI.clickCreateAlertButton();
|
||||
await testSubjects.setValue('alertNameInput', alertName);
|
||||
await testSubjects.click('.index-threshold-SelectOption');
|
||||
await testSubjects.click(`${alertType}-SelectOption`);
|
||||
await testSubjects.click('selectIndexExpression');
|
||||
const comboBox = await find.byCssSelector('#indexSelectSearchBox');
|
||||
await comboBox.click();
|
||||
|
@ -217,5 +218,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
await testSubjects.click('confirmAlertCloseModal > confirmModalCancelButton');
|
||||
await testSubjects.missingOrFail('confirmAlertCloseModal');
|
||||
});
|
||||
|
||||
it('should successfully test valid es_query alert', async () => {
|
||||
const alertName = generateUniqueKey();
|
||||
await defineAlert(alertName, '.es-query');
|
||||
|
||||
// Valid query
|
||||
await testSubjects.setValue('queryJsonEditor', '{"query":{"match_all":{}}}', {
|
||||
clearWithKeyboard: true,
|
||||
});
|
||||
await testSubjects.click('testQuery');
|
||||
await testSubjects.existOrFail('testQuerySuccess');
|
||||
await testSubjects.missingOrFail('testQueryError');
|
||||
|
||||
// Invalid query
|
||||
await testSubjects.setValue('queryJsonEditor', '{"query":{"foo":{}}}', {
|
||||
clearWithKeyboard: true,
|
||||
});
|
||||
await testSubjects.click('testQuery');
|
||||
await testSubjects.missingOrFail('testQuerySuccess');
|
||||
await testSubjects.existOrFail('testQueryError');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { Unionize, UnionToIntersection } from 'utility-types';
|
||||
import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.';
|
||||
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
type SortInstruction = Record<string, SortOrder | { order: SortOrder }>;
|
||||
export type SortOptions = SortOrder | SortInstruction | SortInstruction[];
|
||||
|
||||
|
|
1
x-pack/typings/elasticsearch/index.d.ts
vendored
1
x-pack/typings/elasticsearch/index.d.ts
vendored
|
@ -70,6 +70,7 @@ export interface ESSearchBody {
|
|||
aggs?: AggregationInputMap;
|
||||
track_total_hits?: boolean | number;
|
||||
collapse?: CollapseQuery;
|
||||
search_after?: Array<string | number>;
|
||||
_source?: ESSourceOptions;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue