From 049135192e397f089adce5c50639fcd2d563d8d2 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Fri, 29 Jan 2021 07:45:00 -0500 Subject: [PATCH 01/38] [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> --- docs/user/alerting/alert-types.asciidoc | 43 +- .../alert-types-es-query-conditions.png | Bin 0 -> 97147 bytes .../images/alert-types-es-query-invalid.png | Bin 0 -> 82855 bytes .../images/alert-types-es-query-select.png | Bin 0 -> 57025 bytes .../images/alert-types-es-query-valid.png | Bin 0 -> 79515 bytes x-pack/plugins/alerts/common/index.ts | 1 + .../common/build_sorted_events_query.test.ts | 398 ++++++++++++++++++ .../common/build_sorted_events_query.ts | 93 ++++ x-pack/plugins/stack_alerts/kibana.json | 1 + .../components/index_select_popover.test.tsx | 114 +++++ .../components/index_select_popover.tsx | 239 +++++++++++ .../alert_types/es_query/expression.test.tsx | 235 +++++++++++ .../alert_types/es_query/expression.tsx | 371 ++++++++++++++++ .../public/alert_types/es_query/index.ts | 36 ++ .../public/alert_types/es_query/types.ts | 23 + .../alert_types/es_query/validation.test.ts | 99 +++++ .../public/alert_types/es_query/validation.ts | 96 +++++ .../stack_alerts/public/alert_types/index.ts | 2 + .../alert_types/threshold/expression.tsx | 259 ++---------- .../es_query/action_context.test.ts | 64 +++ .../alert_types/es_query/action_context.ts | 63 +++ .../alert_types/es_query/alert_type.test.ts | 103 +++++ .../server/alert_types/es_query/alert_type.ts | 307 ++++++++++++++ .../es_query/alert_type_params.test.ts | 190 +++++++++ .../alert_types/es_query/alert_type_params.ts | 77 ++++ .../server/alert_types/es_query/index.ts | 19 + .../stack_alerts/server/alert_types/index.ts | 3 +- .../alert_types/index_threshold/README.md | 2 +- .../alert_types/index_threshold/alert_type.ts | 68 +-- .../index_threshold/alert_type_params.ts | 9 +- .../alert_types/lib/comparator_types.ts | 54 +++ .../server/alert_types/lib/index.ts | 7 + .../stack_alerts/server/plugin.test.ts | 21 +- .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../triggers_actions_ui/server/data/index.ts | 1 + .../server/data/lib/index.ts | 1 + .../triggers_actions_ui/server/index.ts | 1 + .../builtin_alert_types/es_query/alert.ts | 251 +++++++++++ .../es_query/create_test_data.ts | 59 +++ .../builtin_alert_types/es_query/index.ts | 14 + .../alerting/builtin_alert_types/index.ts | 1 + .../alert_create_flyout.ts | 26 +- .../typings/elasticsearch/aggregations.d.ts | 2 +- x-pack/typings/elasticsearch/index.d.ts | 1 + 45 files changed, 3072 insertions(+), 294 deletions(-) create mode 100644 docs/user/alerting/images/alert-types-es-query-conditions.png create mode 100644 docs/user/alerting/images/alert-types-es-query-invalid.png create mode 100644 docs/user/alerting/images/alert-types-es-query-select.png create mode 100644 docs/user/alerting/images/alert-types-es-query-valid.png create mode 100644 x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts create mode 100644 x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 7c5a957d1cf794..279739e95b5228 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -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 <> for more information on configuring roles that provide access to this feature. -Currently {kib} provides one stack alert: the <> type. +Currently {kib} provides two stack alerts: <> and <>. [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 <>. Fill in the <>, 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 <>, 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] \ No newline at end of file diff --git a/docs/user/alerting/images/alert-types-es-query-conditions.png b/docs/user/alerting/images/alert-types-es-query-conditions.png new file mode 100644 index 0000000000000000000000000000000000000000..ce2bd6a42a4b5c2256121bf513e751988c7cb3e3 GIT binary patch literal 97147 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
  • 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

    J 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 F{y)ye4x9i0 literal 0 HcmV?d00001 diff --git a/docs/user/alerting/images/alert-types-es-query-select.png b/docs/user/alerting/images/alert-types-es-query-select.png new file mode 100644 index 0000000000000000000000000000000000000000..61fe724ea1412521b8b1bc691a408653fc956e30 GIT binary patch literal 57025 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�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

    w&?CLN152kG2MMpT9&lZ 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 literal 0 HcmV?d00001 diff --git a/docs/user/alerting/images/alert-types-es-query-valid.png b/docs/user/alerting/images/alert-types-es-query-valid.png new file mode 100644 index 0000000000000000000000000000000000000000..1894ad2b445f8d0ac37d0b41c5e23273f12903ff GIT binary patch literal 79515 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 S{q_r_&vRwXr> = { + 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', + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts new file mode 100644 index 00000000000000..92425433bf814c --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts @@ -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 & + Pick, '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; +}; diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index 884d33ef669e5c..80eb177f92024b 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -5,5 +5,6 @@ "kibanaVersion": "kibana", "requiredPlugins": ["alerts", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"], "configPath": ["xpack", "stack_alerts"], + "requiredBundles": ["esUiShared"], "ui": true } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx new file mode 100644 index 00000000000000..5dc7c9248135cc --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -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(); + + 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(); + + 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'); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx new file mode 100644 index 00000000000000..6fe61be0240428 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -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 = ({ + index, + esFields, + timeField, + errors, + onIndexChange, + onTimeFieldChange, +}) => { + const { http } = useKibana().services; + + const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); + const [indexOptions, setIndexOptions] = useState([]); + const [indexPatterns, setIndexPatterns] = useState([]); + const [areIndicesLoading, setAreIndicesLoading] = useState(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 ( +

    + {indexName} + {idx < indices.length - 1 ? ',' : null} +

    + ); + }); + return
    {rows}
    ; + }; + + const closeIndexPopover = () => { + setIndexPopoverOpen(false); + if (timeField === undefined) { + onTimeFieldChange(''); + } + }; + + return ( + 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" + > +
    + + + + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexButtonLabel', { + defaultMessage: 'index', + })} + + + + + + + + } + isInvalid={errors.index.length > 0 && index != null && index.length > 0} + error={errors.index} + helpText={ + + } + > + 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(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([]); + } + }} + /> + + + } + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + error={errors.timeField} + > + 0 && timeField !== undefined} + fullWidth + name="thresholdTimeField" + data-test-subj="thresholdAlertTimeFieldSelect" + value={timeField || ''} + onChange={(e) => { + onTimeFieldChange(e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + onTimeFieldChange(''); + } + }} + /> + +
    +
    + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx new file mode 100644 index 00000000000000..96a45da3d08088 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -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) => ( + { + 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 }; + }; + 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( + {}} + 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({ + 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(); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx new file mode 100644 index 00000000000000..bba0e309783059 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx @@ -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 +> = ({ 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().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( + getDefaultParams() + ); + const [testQueryResult, setTestQueryResult] = useState(null); + const [testQueryError, setTestQueryError] = useState(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 ( + + {hasExpressionErrors ? ( + + + + + + ) : null} + +
    + +
    +
    + + { + 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)} + /> + + +
    + +
    +
    + + + } + isInvalid={errors.esQuery.length > 0} + error={errors.esQuery} + helpText={ + + + + } + > + { + setXJson(xjson); + setParam('esQuery', convertToJson(xjson)); + }} + /> + + + + + + + {testQueryResult && ( + + +

    {testQueryResult}

    +
    +
    + )} + {testQueryError && ( + + +

    {testQueryError}

    +
    +
    + )} + + +
    + +
    +
    + + + setParam('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={(selectedThresholdComparator) => + setParam('thresholdComparator', selectedThresholdComparator) + } + /> + + setParam('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: string) => + setParam('timeWindowUnit', selectedWindowUnit) + } + /> + +
    + ); +}; + +// eslint-disable-next-line import/no-default-export +export { EsQueryAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts new file mode 100644 index 00000000000000..62b343ffd6d2fe --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts @@ -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 { + 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, + }; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts new file mode 100644 index 00000000000000..803c4bde873b44 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -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; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts new file mode 100644 index 00000000000000..15aff9c9a64951 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts @@ -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.' + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts new file mode 100644 index 00000000000000..d54e24e21d61e6 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -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(), + timeField: new Array(), + esQuery: new Array(), + threshold0: new Array(), + threshold1: new Array(), + thresholdComparator: new Array(), + timeWindowSize: new Array(), + }; + 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; +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 1a9710eb08eb09..654bf0a424f091 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -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()); } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx index 8348a797972aeb..00c170e2915049 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx @@ -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().services; - const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); - const [indexPatterns, setIndexPatterns] = useState([]); - const [esFields, setEsFields] = useState([]); - const [indexOptions, setIndexOptions] = useState([]); - const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const [esFields, setEsFields] = useState< + Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }> + >([]); const hasExpressionErrors = !!Object.keys(errors).find( (errorKey) => @@ -139,153 +123,22 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< }); if (indexArray.length > 0) { - const currentEsFields = await getFields(http, indexArray); - const timeFields = getTimeFieldOptions(currentEsFields); - - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); + await refreshEsFields(); } }; - const closeIndexPopover = () => { - setIndexPopoverOpen(false); - if (timeField === undefined) { - setAlertParams('timeField', ''); + const refreshEsFields = async () => { + if (indexArray.length > 0) { + const currentEsFields = await getFields(http, indexArray); + setEsFields(currentEsFields); } }; - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); - useEffect(() => { setDefaultExpressionValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const indexPopover = ( - - - } - isInvalid={errors.index.length > 0 && indexArray.length > 0} - error={errors.index} - helpText={ - - } - > - 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(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', []); - } - }} - /> - - - } - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - error={errors.timeField} - > - 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', ''); - } - }} - /> - - - ); - - const renderIndices = (indices: string[]) => { - const rows = indices.map((s: string, i: number) => { - return ( -

    - {s} - {i < indices.length - 1 ? ',' : null} -

    - ); - }); - return
    {rows}
    ; - }; - return ( {hasExpressionErrors ? ( @@ -304,58 +157,36 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< - 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" - > -
    - - - - {i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel', { - defaultMessage: 'index', - })} - - - - - - + { + setAlertParams('index', indices); - {indexPopover} -
    -
    + // 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) + } + /> { + 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` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts new file mode 100644 index 00000000000000..67d0ac0df8ffeb --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts @@ -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; + +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 }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts new file mode 100644 index 00000000000000..c5f57a056b002f --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -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> = { + 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> = { + 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"` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts new file mode 100644 index 00000000000000..b8190340c4d686 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -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 { + 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 = 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 }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts new file mode 100644 index 00000000000000..09ad66f248fee1 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -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> = { + 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 { + return EsQueryAlertParamsSchema.validate(params); + } +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts new file mode 100644 index 00000000000000..2e7cd15d323e72 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -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; +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, + }, + }); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts new file mode 100644 index 00000000000000..2fa2bed9d8419d --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts @@ -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)); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 21a7ffc4813232..2a343cb49a91bb 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -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; @@ -20,4 +20,5 @@ export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); registerGeoThreshold(params); registerGeoContainment(params); + registerEsQuery(params); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md index 9b0eb23950cc3a..de5b57dfbffc62 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md @@ -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. diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2366a872b855b1..10dfabffddfcfe 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -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([ - [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 { - const fns: Record = { - [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(); - 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; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts index b51545770dd7bf..2c83d5edc255aa 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts @@ -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, + }, + }); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts new file mode 100644 index 00000000000000..cfa824d1596867 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts @@ -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([ + [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 { + const fns: Record = { + [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(); + 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; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts new file mode 100644 index 00000000000000..7e40a7247a4c90 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts @@ -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'; diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 7226c2175a7699..8d69fad4afa465 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -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); }); }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 47267dc36673de..1c058245f04cdb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "条件を定義してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3f78abf14ae384..e7dbc0c161a37e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "定义条件", diff --git a/x-pack/plugins/triggers_actions_ui/server/data/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/index.ts index 6ee2b4bb8a5fe8..cc76af90bcde6e 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/index.ts @@ -13,6 +13,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './lib'; // future enhancement: make these configurable? diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts index 096a928249fd5a..a3fe2220a86fd6 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts @@ -9,4 +9,5 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './core_query_types'; diff --git a/x-pack/plugins/triggers_actions_ui/server/index.ts b/x-pack/plugins/triggers_actions_ui/server/index.ts index abd61f2bd3541f..5e35293419b172 100644 --- a/x-pack/plugins/triggers_actions_ui/server/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/index.ts @@ -14,6 +14,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, MAX_INTERVALS, MAX_GROUPS, DEFAULT_GROUPS, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts new file mode 100644 index 00000000000000..a1ae35a29bf23f --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts new file mode 100644 index 00000000000000..7299827a72253d --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts @@ -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)}`); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts new file mode 100644 index 00000000000000..574f35e123fe87 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts index c0147cbedcdfed..f59ef6829f892a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts @@ -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')); }); } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 352652d9601dc5..52e9422da2da41 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -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'); + }); }); }; diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index 0328877aae8fe1..fcb32fa6c03727 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -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; export type SortOptions = SortOrder | SortInstruction | SortInstruction[]; diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index 049e1e52c66d98..81443947855bc5 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -70,6 +70,7 @@ export interface ESSearchBody { aggs?: AggregationInputMap; track_total_hits?: boolean | number; collapse?: CollapseQuery; + search_after?: Array; _source?: ESSourceOptions; } From da1a4e947a6c49d82adf1cc66d26c376a54e4bd0 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 29 Jan 2021 08:41:36 -0500 Subject: [PATCH 02/38] [Fleet] Install the Fleet Server package during setup (#89224) --- .../plugins/fleet/common/constants/agent.ts | 1 + x-pack/plugins/fleet/common/constants/epm.ts | 2 ++ .../plugins/fleet/common/constants/index.ts | 9 ++++++ .../server/collectors/agent_collectors.ts | 4 ++- x-pack/plugins/fleet/server/plugin.ts | 11 +++++-- .../server/services/fleet_server_migration.ts | 30 +++++++++++++++++-- x-pack/plugins/fleet/server/services/setup.ts | 29 ++++++++++++++++++ 7 files changed, 80 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/agent.ts b/x-pack/plugins/fleet/common/constants/agent.ts index 8bfb32b5ed2b0f..2e9161ca1c5341 100644 --- a/x-pack/plugins/fleet/common/constants/agent.ts +++ b/x-pack/plugins/fleet/common/constants/agent.ts @@ -24,3 +24,4 @@ export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS = 1000; export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL = 5; export const AGENTS_INDEX = '.fleet-agents'; +export const AGENT_ACTIONS_INDEX = '.fleet-actions'; diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index 5ba4de914c7249..ece669293fdffc 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -8,6 +8,8 @@ export const ASSETS_SAVED_OBJECT_TYPE = 'epm-packages-assets'; export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; export const MAX_TIME_COMPLETE_INSTALL = 60000; +export const FLEET_SERVER_PACKAGE = 'fleet_server'; + export const requiredPackages = { System: 'system', Endpoint: 'endpoint', diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index bdc5714f7e2fed..409375f81d6fed 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -19,3 +19,12 @@ export * from './settings'; // for the actual setting to differ from the default. Can we retrieve the real // setting in the future? export const SO_SEARCH_LIMIT = 10000; + +export const FLEET_SERVER_INDICES = [ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', +]; diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8925f3386dfb84..fcead1bc897492 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -6,6 +6,7 @@ import { ElasticsearchClient, SavedObjectsClient } from 'kibana/server'; import * as AgentService from '../services/agents'; +import { isFleetServerSetup } from '../services/fleet_server_migration'; export interface AgentUsage { total: number; online: number; @@ -18,7 +19,7 @@ export const getAgentUsage = async ( esClient?: ElasticsearchClient ): Promise => { // TODO: unsure if this case is possible at all. - if (!soClient || !esClient) { + if (!soClient || !esClient || !(await isFleetServerSetup())) { return { total: 0, online: 0, @@ -26,6 +27,7 @@ export const getAgentUsage = async ( offline: 0, }; } + const { total, online, error, offline } = await AgentService.getAgentStatusForAgentPolicy( soClient, esClient diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 253b614dc228aa..a0eb1547a3d631 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -81,7 +81,7 @@ import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; -import { runFleetServerMigration } from './services/fleet_server_migration'; +import { isFleetServerSetup } from './services/fleet_server_migration'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -299,7 +299,14 @@ export class FleetPlugin if (fleetServerEnabled) { // We need licence to be initialized before using the SO service. await this.licensing$.pipe(first()).toPromise(); - await runFleetServerMigration(); + + const fleetSetup = await isFleetServerSetup(); + + if (!fleetSetup) { + this.logger?.warn( + 'Extra setup is needed to be able to use central management for agent, please visit the Fleet app in Kibana.' + ); + } } return { diff --git a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts index 1a50b5c9df767c..44065a9346c5de 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts @@ -9,15 +9,39 @@ import { ENROLLMENT_API_KEYS_INDEX, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, FleetServerEnrollmentAPIKey, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollment_api_key_so'; import { appContextService } from './app_context'; +import { getInstallation } from './epm/packages'; + +export async function isFleetServerSetup() { + const pkgInstall = await getInstallation({ + savedObjectsClient: getInternalUserSOClient(), + pkgName: FLEET_SERVER_PACKAGE, + }); + + if (!pkgInstall) { + return false; + } + + const esClient = appContextService.getInternalUserESClient(); + + const exists = await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + return res.statusCode !== 404; + }) + ); + + return exists.every((exist) => exist === true); +} export async function runFleetServerMigration() { - const logger = appContextService.getLogger(); - logger.info('Starting fleet server migration'); await migrateEnrollmentApiKeys(); - logger.info('Fleet server migration finished'); } function getInternalUserSOClient() { diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 0dcdfeb7b38013..ff96e2724c8921 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -11,6 +11,7 @@ import { agentPolicyService } from './agent_policy'; import { outputService } from './output'; import { ensureInstalledDefaultPackages, + ensureInstalledPackage, ensurePackagesCompletedInstall, } from './epm/packages/install'; import { @@ -20,6 +21,8 @@ import { Installation, Output, DEFAULT_AGENT_POLICIES_PACKAGES, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { getPackageInfo } from './epm/packages'; @@ -29,6 +32,8 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; +import { appContextService } from './app_context'; +import { runFleetServerMigration } from './fleet_server_migration'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -77,6 +82,15 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); + if (appContextService.getConfig()?.agents.fleetServerEnabled) { + await ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: FLEET_SERVER_PACKAGE, + callCluster, + }); + await ensureFleetServerIndicesCreated(esClient); + await runFleetServerMigration(); + } // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { @@ -144,6 +158,21 @@ async function updateFleetRoleIfExists(callCluster: CallESAsCurrentUser) { return putFleetRole(callCluster); } +async function ensureFleetServerIndicesCreated(esClient: ElasticsearchClient) { + await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + if (res.statusCode === 404) { + await esClient.indices.create({ + index, + }); + } + }) + ); +} + async function putFleetRole(callCluster: CallESAsCurrentUser) { return callCluster('transport.request', { method: 'PUT', From e8e8f78b39c65ffd15fc88cb0549f9b8b2fc0bf2 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 29 Jan 2021 16:49:51 +0300 Subject: [PATCH 03/38] [Vega] Use mapbox instead of leaflet (#88605) * [WIP][Vega] Use mapbox instead of leaflet #78395 add MapServiceSettings class some work add tms_raster_layer add LayerParameters type clenup view.ts some cleeanup fix grammar some refactoring and add attribution control Some refactoring Add some validation for zoom settings and destroy handler Some refactoring some work fix bundle size Move getZoomSettings to the separate file update licence some work move logger to createViewConfig add throttling for updating vega layer * move EMSClient to a separate bundle * [unit testing] add tests for validation_helper.ts * [Bundle optimization] lazy loading of '@elastic/ems-client' only if user open map layer * [Map] fix cursor: crosshair -> auto * [unit testing] add tests for tms_raster_layer.test * [unit testing] add tests for vega_layer.ts * VSI related code was moved into a separate file / unit tests were added * Add functional test for vega map * [unit testing] add tests for map_service_setting.ts * Add unload in function test and delete some unneeded code from test * road_map -> road_map_desaturated * [unit testing] add more tests for map_service_settings.test.ts * Add unit tests for view.ts * Fix some remarks * Fix unit tests * remove tms_tile_layers enum * [unit testing] fix map_service_settings.test.ts * Fix unit test for view.ts * Fix some comments * Fix type check * Fix CI Co-authored-by: Alexey Antonov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../vega_visualization.test.js.snap | 2 - src/plugins/vis_type_vega/public/plugin.ts | 13 +- src/plugins/vis_type_vega/public/services.ts | 11 +- .../public/test_utils/vega_map_test.json | 2 +- .../public/vega_view/vega_base_view.d.ts | 11 +- .../public/vega_view/vega_base_view.js | 9 +- .../public/vega_view/vega_map_layer.js | 28 --- .../public/vega_view/vega_map_view.js | 168 --------------- .../vega_view/vega_map_view/constants.ts | 37 ++++ .../layers/index.ts} | 5 +- .../layers/tms_raster_layer.test.ts | 54 +++++ .../vega_map_view/layers/tms_raster_layer.ts | 37 ++++ .../vega_view/vega_map_view/layers/types.ts | 15 ++ .../vega_map_view/layers/vega_layer.test.ts | 65 ++++++ .../vega_map_view/layers/vega_layer.ts | 47 +++++ .../map_service_settings.test.ts | 105 ++++++++++ .../vega_map_view/map_service_settings.ts | 88 ++++++++ .../vega_view/vega_map_view/utils/index.ts | 10 + .../utils/validation_helper.test.ts | 112 ++++++++++ .../vega_map_view/utils/validation_helper.ts | 80 +++++++ .../vega_map_view/utils/vsi_helper.test.ts | 80 +++++++ .../vega_map_view/utils/vsi_helper.ts | 24 +++ .../vega_map_view/vega_map_view.scss | 7 + .../vega_view/vega_map_view/view.test.ts | 197 ++++++++++++++++++ .../public/vega_view/vega_map_view/view.ts | 181 ++++++++++++++++ .../public/vega_view/vega_view.js | 2 - .../public/vega_visualization.test.js | 30 --- .../public/vega_visualization.ts | 2 +- src/plugins/vis_type_vega/tsconfig.json | 4 +- .../fixtures/es_archiver/visualize/data.json | 21 ++ test/visual_regression/config.ts | 6 +- test/visual_regression/tests/vega/index.ts | 27 +++ .../tests/vega/vega_map_visualization.ts | 34 +++ 33 files changed, 1265 insertions(+), 249 deletions(-) delete mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js delete mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view.js create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts rename src/plugins/vis_type_vega/public/vega_view/{vega_map_view.d.ts => vega_map_view/layers/index.ts} (77%) create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts create mode 100644 src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts create mode 100644 test/visual_regression/tests/vega/index.ts create mode 100644 test/visual_regression/tests/vega/vega_map_visualization.ts diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 8b813ee06b1b3d..c70c4406a34f2d 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"
    "`; - exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
    "`; exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
    • \\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable
    "`; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 376ef84de23c32..c18a7d4dfcfbda 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -17,17 +17,18 @@ import { setData, setInjectedVars, setUISettings, - setMapsLegacyConfig, setInjectedMetadata, + setMapServiceSettings, } from './services'; import { createVegaFn } from './vega_fn'; import { createVegaTypeDefinition } from './vega_type'; -import { IServiceSettings } from '../../maps_legacy/public'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { ConfigSchema } from '../config'; import { getVegaInspectorView } from './vega_inspector'; import { getVegaVisRenderer } from './vega_vis_renderer'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; /** @internal */ export interface VegaVisualizationDependencies { @@ -44,7 +45,7 @@ export interface VegaPluginSetupDependencies { visualizations: VisualizationsSetup; inspector: InspectorSetup; data: DataPublicPluginSetup; - mapsLegacy: any; + mapsLegacy: MapsLegacyPluginSetup; } /** @internal */ @@ -68,8 +69,12 @@ export class VegaPlugin implements Plugin, void> { enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true), }); + setUISettings(core.uiSettings); - setMapsLegacyConfig(mapsLegacy.config); + + setMapServiceSettings( + new MapServiceSettings(mapsLegacy.config, this.initializerContext.env.packageInfo.version) + ); const visualizationDependencies: Readonly = { core, diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index 157e355f93434c..3e5d890c39ff48 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -10,7 +10,7 @@ import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/publi import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; -import { MapsLegacyConfig } from '../../maps_legacy/config'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; export const [getData, setData] = createGetterSetter('Data'); @@ -24,13 +24,14 @@ export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< CoreStart['injectedMetadata'] >('InjectedMetadata'); +export const [ + getMapServiceSettings, + setMapServiceSettings, +] = createGetterSetter('MapServiceSettings'); + export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ enableExternalUrls: boolean; emsTileLayerId: unknown; }>('InjectedVars'); -export const [getMapsLegacyConfig, setMapsLegacyConfig] = createGetterSetter( - 'MapsLegacyConfig' -); - export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; diff --git a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json index 9100de38ae3878..a7e3b9dc7e024f 100644 --- a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json +++ b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json @@ -1,7 +1,7 @@ { "$schema": "https://vega.github.io/schema/vega/v5.json", "config": { - "kibana": { "renderer": "svg", "type": "map", "mapStyle": false} + "kibana": { "type": "map", "mapStyle": "default", "latitude": 25, "longitude": -70, "zoom": 3} }, "width": 512, "height": 512, diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index d63288745986cb..15132483b36597 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -18,12 +18,21 @@ interface VegaViewParams { serviceSettings: IServiceSettings; filterManager: DataPublicPluginStart['query']['filterManager']; timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; - // findIndex: (index: string) => Promise<...>; } export class VegaBaseView { constructor(params: VegaViewParams); init(): Promise; onError(error: any): void; + onWarn(error: any): void; + setView(map: any): void; + setDebugValues(view: any, spec: any, vlspec: any): void; + _addDestroyHandler(handler: Function): void; + destroy(): Promise; + + _$container: any; + _parser: any; + _vegaViewConfig: any; + _serviceSettings: any; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 6971adaa55ec3b..7c3915955419f9 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -160,8 +160,6 @@ export class VegaBaseView { createViewConfig() { const config = { - // eslint-disable-next-line import/namespace - logLevel: vega.Warn, // note: eslint has a false positive here renderer: this._parser.renderer, }; @@ -189,6 +187,13 @@ export class VegaBaseView { }; config.loader = loader; + const logger = vega.logger(vega.Warn); + + logger.warn = this.onWarn.bind(this); + logger.error = this.onError.bind(this); + + config.logger = logger; + return config; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js deleted file mode 100644 index bf91b50ed9cf68..00000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { KibanaMapLayer } from '../../../maps_legacy/public'; - -export class VegaMapLayer extends KibanaMapLayer { - constructor(spec, options, leaflet) { - super(); - - // Used by super.getAttributions() - this._attribution = options.attribution; - delete options.attribution; - this._leafletLayer = leaflet.vega(spec, options); - } - - getVegaView() { - return this._leafletLayer._view; - } - - getVegaSpec() { - return this._leafletLayer._spec; - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js deleted file mode 100644 index 693045edeb7d01..00000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { vega } from '../lib/vega'; -import { VegaBaseView } from './vega_base_view'; -import { VegaMapLayer } from './vega_map_layer'; -import { getMapsLegacyConfig, getUISettings } from '../services'; -import { lazyLoadMapsLegacyModules, TMS_IN_YML_ID } from '../../../maps_legacy/public'; - -const isUserConfiguredTmsLayer = ({ tilemap }) => Boolean(tilemap.url); - -export class VegaMapView extends VegaBaseView { - constructor(opts) { - super(opts); - } - - async getMapStyleOptions() { - const isDarkMode = getUISettings().get('theme:darkMode'); - const mapsLegacyConfig = getMapsLegacyConfig(); - const tmsServices = await this._serviceSettings.getTMSServices(); - const mapConfig = this._parser.mapConfig; - - let mapStyle; - - if (mapConfig.mapStyle !== 'default') { - mapStyle = mapConfig.mapStyle; - } else { - if (isUserConfiguredTmsLayer(mapsLegacyConfig)) { - mapStyle = TMS_IN_YML_ID; - } else { - mapStyle = mapsLegacyConfig.emsTileLayerId.bright; - } - } - - const mapOptions = tmsServices.find((s) => s.id === mapStyle); - - if (!mapOptions) { - this.onWarn( - i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { - defaultMessage: '{mapStyleParam} was not found', - values: { mapStyleParam: `"mapStyle":${mapStyle}` }, - }) - ); - return null; - } - - return { - ...mapOptions, - ...(await this._serviceSettings.getAttributesForTMSLayer(mapOptions, true, isDarkMode)), - }; - } - - async _initViewCustomizations() { - const mapConfig = this._parser.mapConfig; - let baseMapOpts; - let limitMinZ = 0; - let limitMaxZ = 25; - - // In some cases, Vega may be initialized twice, e.g. after awaiting... - if (!this._$container) return; - - if (mapConfig.mapStyle !== false) { - baseMapOpts = await this.getMapStyleOptions(); - - if (baseMapOpts) { - limitMinZ = baseMapOpts.minZoom; - limitMaxZ = baseMapOpts.maxZoom; - } - } - - const validate = (name, value, dflt, min, max) => { - if (value === undefined) { - value = dflt; - } else if (value < min) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { - defaultMessage: 'Resetting {name} to {min}', - values: { name: `"${name}"`, min }, - }) - ); - value = min; - } else if (value > max) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { - defaultMessage: 'Resetting {name} to {max}', - values: { name: `"${name}"`, max }, - }) - ); - value = max; - } - return value; - }; - - let minZoom = validate('minZoom', mapConfig.minZoom, limitMinZ, limitMinZ, limitMaxZ); - let maxZoom = validate('maxZoom', mapConfig.maxZoom, limitMaxZ, limitMinZ, limitMaxZ); - if (minZoom > maxZoom) { - this.onWarn( - i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { - defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', - values: { - minZoomPropertyName: '"minZoom"', - maxZoomPropertyName: '"maxZoom"', - }, - }) - ); - [minZoom, maxZoom] = [maxZoom, minZoom]; - } - const zoom = validate('zoom', mapConfig.zoom, 2, minZoom, maxZoom); - - // let maxBounds = null; - // if (mapConfig.maxBounds) { - // const b = mapConfig.maxBounds; - // eslint-disable-next-line no-undef - // maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2])); - // } - - const modules = await lazyLoadMapsLegacyModules(); - - this._kibanaMap = new modules.KibanaMap(this._$container.get(0), { - zoom, - minZoom, - maxZoom, - center: [mapConfig.latitude, mapConfig.longitude], - zoomControl: mapConfig.zoomControl, - scrollWheelZoom: mapConfig.scrollWheelZoom, - }); - - if (baseMapOpts) { - this._kibanaMap.setBaseLayer({ - baseLayerType: 'tms', - options: baseMapOpts, - }); - } - - const vegaMapLayer = new VegaMapLayer( - this._parser.spec, - { - vega, - bindingsContainer: this._$controls.get(0), - delayRepaint: mapConfig.delayRepaint, - viewConfig: this._vegaViewConfig, - onWarning: this.onWarn.bind(this), - onError: this.onError.bind(this), - }, - modules.L - ); - - this._kibanaMap.addLayer(vegaMapLayer); - - this._addDestroyHandler(() => { - this._kibanaMap.removeLayer(vegaMapLayer); - if (baseMapOpts) { - this._kibanaMap.setBaseLayer(null); - } - this._kibanaMap.destroy(); - }); - - const vegaView = vegaMapLayer.getVegaView(); - await this.setView(vegaView); - this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts new file mode 100644 index 00000000000000..ced1dc1bdc2177 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { TMS_IN_YML_ID } from '../../../../maps_legacy/public'; + +export const vegaLayerId = 'vega'; +export const userConfiguredLayerId = TMS_IN_YML_ID; +export const defaultMapConfig = { + maxZoom: 20, + minZoom: 0, + tileSize: 256, +}; + +export const defaultMabBoxStyle = { + /** + * according to the MapBox documentation that value should be '8' + * @see (https://docs.mapbox.com/mapbox-gl-js/style-spec/root/#version) + */ + version: 8, + sources: {}, + layers: [], +}; + +export const defaultProjection = { + name: 'projection', + type: 'mercator', + scale: { signal: '512*pow(2,zoom)/2/PI' }, + rotate: [{ signal: '-longitude' }, 0, 0], + center: [0, { signal: 'latitude' }], + translate: [{ signal: 'width/2' }, { signal: 'height/2' }], + fit: false, +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts similarity index 77% rename from src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts rename to src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts index f101372f5bbce7..c0ca7f04810d04 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts @@ -6,6 +6,5 @@ * Public License, v 1. */ -import { VegaBaseView } from './vega_base_view'; - -export class VegaMapView extends VegaBaseView {} +export { initTmsRasterLayer } from './tms_raster_layer'; +export { initVegaLayer } from './vega_layer'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts new file mode 100644 index 00000000000000..ea74a48dc9a746 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts @@ -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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { initTmsRasterLayer } from './tms_raster_layer'; + +type InitTmsRasterLayerParams = Parameters[0]; + +type IdType = InitTmsRasterLayerParams['id']; +type MapType = InitTmsRasterLayerParams['map']; +type ContextType = InitTmsRasterLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_tms_layer_id'; + map = ({ + addSource: jest.fn(), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + maxZoom: 10, + minZoom: 2, + tileSize: 512, + }; + }); + + test('should register a new layer', () => { + initTmsRasterLayer({ id, map, context }); + + expect(map.addLayer).toHaveBeenCalledWith({ + id: 'foo_tms_layer_id', + maxzoom: 10, + minzoom: 2, + source: 'foo_tms_layer_id', + type: 'raster', + }); + + expect(map.addSource).toHaveBeenCalledWith('foo_tms_layer_id', { + scheme: 'xyz', + tileSize: 512, + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + type: 'raster', + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts new file mode 100644 index 00000000000000..03fdce9bd8d938 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { LayerParameters } from './types'; + +interface TMSRasterLayerContext { + tiles: string[]; + maxZoom: number; + minZoom: number; + tileSize: number; +} + +export const initTmsRasterLayer = ({ + id, + map, + context: { tiles, maxZoom, minZoom, tileSize }, +}: LayerParameters) => { + map.addSource(id, { + type: 'raster', + tiles, + tileSize, + scheme: 'xyz', + }); + + map.addLayer({ + id, + type: 'raster', + source: id, + maxzoom: maxZoom, + minzoom: minZoom, + }); +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts new file mode 100644 index 00000000000000..1b7ac79312329b --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map } from 'mapbox-gl'; + +export interface LayerParameters = {}> { + id: string; + map: Map; + context: TContext; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts new file mode 100644 index 00000000000000..97d231c5f7a6f2 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { initVegaLayer } from './vega_layer'; + +type InitVegaLayerParams = Parameters[0]; + +type IdType = InitVegaLayerParams['id']; +type MapType = InitVegaLayerParams['map']; +type ContextType = InitVegaLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_vega_layer_id'; + map = ({ + getCanvasContainer: () => document.createElement('div'), + getCanvas: () => ({ + style: { + width: 100, + height: 100, + }, + }), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + vegaView: { + initialize: jest.fn(), + }, + updateVegaView: jest.fn(), + }; + }); + + test('should register a new custom layer', () => { + initVegaLayer({ id, map, context }); + + const calledWith = (map.addLayer as jest.MockedFunction).mock.calls[0][0]; + expect(calledWith).toHaveProperty('id', 'foo_vega_layer_id'); + expect(calledWith).toHaveProperty('type', 'custom'); + }); + + test('should initialize vega container on "onAdd" hook', () => { + initVegaLayer({ id, map, context }); + const { onAdd } = (map.addLayer as jest.MockedFunction).mock.calls[0][0]; + + onAdd(map); + expect(context.vegaView.initialize).toHaveBeenCalled(); + }); + + test('should update vega view on "render" hook', () => { + initVegaLayer({ id, map, context }); + const { render } = (map.addLayer as jest.MockedFunction).mock.calls[0][0]; + + expect(context.updateVegaView).not.toHaveBeenCalled(); + render(); + expect(context.updateVegaView).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts new file mode 100644 index 00000000000000..a9b650fe4c58d0 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map, CustomLayerInterface } from 'mapbox-gl'; +import type { LayerParameters } from './types'; + +// @ts-ignore +import { vega } from '../../lib/vega'; + +export interface VegaLayerContext { + vegaView: vega.View; + updateVegaView: (map: Map, view: vega.View) => void; +} + +export function initVegaLayer({ + id, + map: mapInstance, + context: { vegaView, updateVegaView }, +}: LayerParameters) { + const vegaLayer: CustomLayerInterface = { + id, + type: 'custom', + onAdd(map: Map) { + const mapContainer = map.getCanvasContainer(); + const mapCanvas = map.getCanvas(); + const vegaContainer = document.createElement('div'); + + vegaContainer.style.position = 'absolute'; + vegaContainer.style.top = '0px'; + vegaContainer.style.width = mapCanvas.style.width; + vegaContainer.style.height = mapCanvas.style.height; + + mapContainer.appendChild(vegaContainer); + vegaView.initialize(vegaContainer); + }, + render() { + updateVegaView(mapInstance, vegaView); + }, + }; + + mapInstance.addLayer(vegaLayer); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts new file mode 100644 index 00000000000000..0a477e5f62a7a1 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { get } from 'lodash'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; + +import { MapServiceSettings, getAttributionsForTmsService } from './map_service_settings'; +import { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { EMSClient, TMSService } from '@elastic/ems-client'; +import { setUISettings } from '../../services'; + +const getPrivateField = (mapServiceSettings: MapServiceSettings, privateField: string) => + get(mapServiceSettings, privateField) as T; + +describe('vega_map_view/map_service_settings', () => { + describe('MapServiceSettings', () => { + const appVersion = '99'; + let config: MapsLegacyConfig; + let getUiSettingsMockedValue: any; + + beforeEach(() => { + config = { + emsTileLayerId: { + desaturated: 'road_map_desaturated', + dark: 'dark_map', + }, + } as MapsLegacyConfig; + setUISettings({ + ...uiSettingsServiceMock.createSetupContract(), + get: () => getUiSettingsMockedValue, + }); + }); + + test('should be able to create instance of MapServiceSettings', () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(mapServiceSettings instanceof MapServiceSettings).toBeTruthy(); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeFalsy(); + expect(mapServiceSettings.defaultTmsLayer()).toBe('road_map_desaturated'); + }); + + test('should be able to set user configured base layer through config', () => { + const mapServiceSettings = new MapServiceSettings( + { + ...config, + tilemap: { + url: 'http://some.tile.com/map/{z}/{x}/{y}.jpg', + options: { + attribution: 'attribution', + minZoom: 0, + maxZoom: 4, + }, + }, + }, + appVersion + ); + + expect(mapServiceSettings.defaultTmsLayer()).toBe('TMS in config/kibana.yml'); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeTruthy(); + }); + + test('should load ems client only on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(getPrivateField(mapServiceSettings, 'emsClient')).toBeUndefined(); + + await mapServiceSettings.getTmsService('road_map'); + + expect( + getPrivateField(mapServiceSettings, 'emsClient') instanceof EMSClient + ).toBeTruthy(); + }); + + test('should set isDarkMode value on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + getUiSettingsMockedValue = true; + + expect(getPrivateField(mapServiceSettings, 'isDarkMode')).toBeFalsy(); + + await mapServiceSettings.getTmsService('road_map'); + + expect(getPrivateField(mapServiceSettings, 'isDarkMode')).toBeTruthy(); + }); + + test('getAttributionsForTmsService method should return attributes in a correct form', () => { + const tmsService = ({ + getAttributions: jest.fn(() => [ + { url: 'https://fist_attr.com', label: 'fist_attr' }, + { url: 'https://second_attr.com', label: 'second_attr' }, + ]), + } as unknown) as TMSService; + + expect(getAttributionsForTmsService(tmsService)).toMatchInlineSnapshot(` + Array [ + "fist_attr", + "second_attr", + ] + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts new file mode 100644 index 00000000000000..92dfc873e2715a --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import type { EMSClient, TMSService } from '@elastic/ems-client'; +import { getUISettings } from '../../services'; +import { userConfiguredLayerId } from './constants'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; + +type EmsClientConfig = ConstructorParameters[0]; + +const hasUserConfiguredTmsService = (config: MapsLegacyConfig) => Boolean(config.tilemap?.url); + +const initEmsClientAsync = async (config: Partial) => { + /** + * Build optimization: '@elastic/ems-client' should be loaded from a separate chunk + */ + const emsClientModule = await import('@elastic/ems-client'); + + return new emsClientModule.EMSClient({ + language: i18n.getLocale(), + appName: 'kibana', + // Wrap to avoid errors passing window fetch + fetchFunction(input: RequestInfo, init?: RequestInit) { + return fetch(input, init); + }, + ...config, + } as EmsClientConfig); +}; + +export class MapServiceSettings { + private emsClient?: EMSClient; + private isDarkMode: boolean = false; + + constructor(public config: MapsLegacyConfig, private appVersion: string) {} + + private isInitialized() { + return Boolean(this.emsClient); + } + + public hasUserConfiguredTmsLayer() { + return hasUserConfiguredTmsService(this.config); + } + + public defaultTmsLayer() { + const { dark, desaturated } = this.config.emsTileLayerId; + + if (this.hasUserConfiguredTmsLayer()) { + return userConfiguredLayerId; + } + + return this.isDarkMode ? dark : desaturated; + } + + private async initialize() { + this.isDarkMode = getUISettings().get('theme:darkMode'); + + this.emsClient = await initEmsClientAsync({ + appVersion: this.appVersion, + fileApiUrl: this.config.emsFileApiUrl, + tileApiUrl: this.config.emsTileApiUrl, + landingPageUrl: this.config.emsLandingPageUrl, + }); + } + + public async getTmsService(tmsTileLayer: string) { + if (!this.isInitialized()) { + await this.initialize(); + } + return this.emsClient?.findTMSServiceById(tmsTileLayer); + } +} + +export function getAttributionsForTmsService(tmsService: TMSService) { + return tmsService.getAttributions().map(({ label, url }) => { + const anchorTag = document.createElement('a'); + + anchorTag.textContent = label; + anchorTag.setAttribute('rel', 'noreferrer noopener'); + anchorTag.setAttribute('href', url); + + return anchorTag.outerHTML; + }); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts new file mode 100644 index 00000000000000..921e604354b2ef --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { validateZoomSettings } from './validation_helper'; +export { injectMapPropsIntoSpec } from './vsi_helper'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts new file mode 100644 index 00000000000000..c2eb37980b7418 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { validateZoomSettings } from './validation_helper'; + +type ValidateZoomSettingsParams = Parameters; + +type MapConfigType = ValidateZoomSettingsParams[0]; +type LimitsType = ValidateZoomSettingsParams[1]; +type OnWarnType = ValidateZoomSettingsParams[2]; + +describe('vega_map_view/validation_helper', () => { + describe('validateZoomSettings', () => { + let mapConfig: MapConfigType; + let limits: LimitsType; + let onWarn: OnWarnType; + + beforeEach(() => { + onWarn = jest.fn(); + mapConfig = { + maxZoom: 10, + minZoom: 5, + zoom: 5, + }; + limits = { + maxZoom: 15, + minZoom: 2, + }; + }); + + test('should return validated interval', () => { + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 10, + minZoom: 5, + zoom: 5, + }); + }); + + test('should return default interval in case if mapConfig not provided', () => { + mapConfig = {} as MapConfigType; + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 15, + minZoom: 2, + zoom: 3, + }); + }); + + test('should reset MaxZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + maxZoom: 20, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "maxZoom" to 15'); + expect(result.maxZoom).toEqual(15); + }); + + test('should reset MinZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + minZoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "minZoom" to 2'); + expect(result.minZoom).toEqual(2); + }); + + test('should reset Zoom if the passed value is greater than the max limit', () => { + mapConfig = { + ...mapConfig, + zoom: 45, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 10'); + expect(result.zoom).toEqual(10); + }); + + test('should reset Zoom if the passed value is greater than the min limit', () => { + mapConfig = { + ...mapConfig, + zoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 5'); + expect(result.zoom).toEqual(5); + }); + + test('should swap min <--> max values', () => { + mapConfig = { + maxZoom: 10, + minZoom: 15, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('"minZoom" and "maxZoom" have been swapped'); + expect(result).toEqual({ maxZoom: 15, minZoom: 10, zoom: 10 }); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts new file mode 100644 index 00000000000000..5e6f45790ae2d4 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +function validate( + name: string, + value: number, + defaultValue: number, + min: number, + max: number, + onWarn: (message: string) => void +) { + if (value === undefined) { + value = defaultValue; + } else if (value < min) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { + defaultMessage: 'Resetting {name} to {min}', + values: { name: `"${name}"`, min }, + }) + ); + value = min; + } else if (value > max) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { + defaultMessage: 'Resetting {name} to {max}', + values: { name: `"${name}"`, max }, + }) + ); + value = max; + } + return value; +} + +export function validateZoomSettings( + mapConfig: { + maxZoom: number; + minZoom: number; + zoom?: number; + }, + limits: { + maxZoom: number; + minZoom: number; + }, + onWarn: (message: any) => void +) { + const DEFAULT_ZOOM = 3; + + let { maxZoom, minZoom, zoom = DEFAULT_ZOOM } = mapConfig; + + minZoom = validate('minZoom', minZoom, limits.minZoom, limits.minZoom, limits.maxZoom, onWarn); + maxZoom = validate('maxZoom', maxZoom, limits.maxZoom, limits.minZoom, limits.maxZoom, onWarn); + + if (minZoom > maxZoom) { + onWarn( + i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { + defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', + values: { + minZoomPropertyName: '"minZoom"', + maxZoomPropertyName: '"maxZoom"', + }, + }) + ); + [minZoom, maxZoom] = [maxZoom, minZoom]; + } + + zoom = validate('zoom', zoom, DEFAULT_ZOOM, minZoom, maxZoom, onWarn); + + return { + zoom, + minZoom, + maxZoom, + }; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts new file mode 100644 index 00000000000000..e671b9059f3580 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { injectMapPropsIntoSpec } from './vsi_helper'; +import { VegaSpec } from '../../../data_model/types'; + +describe('vega_map_view/vsi_helper', () => { + describe('injectMapPropsIntoSpec', () => { + test('should inject map properties into vega spec', () => { + const spec = ({ + $schema: 'https://vega.github.io/schema/vega/v5.json', + config: { + kibana: { type: 'map', latitude: 25, longitude: -70, zoom: 3 }, + }, + } as unknown) as VegaSpec; + + expect(injectMapPropsIntoSpec(spec)).toMatchInlineSnapshot(` + Object { + "$schema": "https://vega.github.io/schema/vega/v5.json", + "autosize": "none", + "config": Object { + "kibana": Object { + "latitude": 25, + "longitude": -70, + "type": "map", + "zoom": 3, + }, + }, + "projections": Array [ + Object { + "center": Array [ + 0, + Object { + "signal": "latitude", + }, + ], + "fit": false, + "name": "projection", + "rotate": Array [ + Object { + "signal": "-longitude", + }, + 0, + 0, + ], + "scale": Object { + "signal": "512*pow(2,zoom)/2/PI", + }, + "translate": Array [ + Object { + "signal": "width/2", + }, + Object { + "signal": "height/2", + }, + ], + "type": "mercator", + }, + ], + "signals": Array [ + Object { + "name": "zoom", + }, + Object { + "name": "latitude", + }, + Object { + "name": "longitude", + }, + ], + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts new file mode 100644 index 00000000000000..0022f686376596 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +// @ts-expect-error +// eslint-disable-next-line import/no-extraneous-dependencies +import Vsi from 'vega-spec-injector'; + +import { VegaSpec } from '../../../data_model/types'; +import { defaultProjection } from '../constants'; + +export const injectMapPropsIntoSpec = (spec: VegaSpec) => { + const vsi = new Vsi(); + + vsi.overrideField(spec, 'autosize', 'none'); + vsi.addToList(spec, 'signals', ['zoom', 'latitude', 'longitude']); + vsi.addToList(spec, 'projections', [defaultProjection]); + + return spec; +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss new file mode 100644 index 00000000000000..33e63e7ef317c1 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss @@ -0,0 +1,7 @@ +@import '~mapbox-gl/dist/mapbox-gl.css'; + +.vgaVis { + .mapboxgl-canvas-container { + cursor: auto; + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts new file mode 100644 index 00000000000000..fd176e5d20a2f5 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import 'jest-canvas-mock'; + +import type { TMSService } from '@elastic/ems-client'; +import { VegaMapView } from './view'; +import { VegaViewParams } from '../vega_base_view'; +import { VegaParser } from '../../data_model/vega_parser'; +import { TimeCache } from '../../data_model/time_cache'; +import { SearchAPI } from '../../data_model/search_api'; +import vegaMap from '../../test_utils/vega_map_test.json'; +import { coreMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { IServiceSettings } from '../../../../maps_legacy/public'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { MapServiceSettings } from './map_service_settings'; +import { userConfiguredLayerId } from './constants'; +import { + setInjectedVars, + setData, + setNotifications, + setMapServiceSettings, + setUISettings, +} from '../../services'; + +jest.mock('../../lib/vega', () => ({ + vega: jest.requireActual('vega'), + vegaLite: jest.requireActual('vega-lite'), +})); + +jest.mock('mapbox-gl', () => ({ + Map: jest.fn().mockImplementation(() => ({ + getLayer: () => '', + removeLayer: jest.fn(), + once: (eventName: string, handler: Function) => handler(), + remove: () => jest.fn(), + getCanvas: () => ({ clientWidth: 512, clientHeight: 512 }), + getCenter: () => ({ lat: 20, lng: 20 }), + getZoom: () => 3, + addControl: jest.fn(), + addLayer: jest.fn(), + })), + MapboxOptions: jest.fn(), + NavigationControl: jest.fn(), +})); + +jest.mock('./layers', () => ({ + initVegaLayer: jest.fn(), + initTmsRasterLayer: jest.fn(), +})); + +import { initVegaLayer, initTmsRasterLayer } from './layers'; +import { Map, NavigationControl } from 'mapbox-gl'; + +describe('vega_map_view/view', () => { + describe('VegaMapView', () => { + const coreStart = coreMock.createStart(); + const dataPluginStart = dataPluginMock.createStartContract(); + const mockGetServiceSettings = async () => { + return {} as IServiceSettings; + }; + let vegaParser: VegaParser; + + setInjectedVars({ + emsTileLayerId: {}, + enableExternalUrls: true, + }); + setData(dataPluginStart); + setNotifications(coreStart.notifications); + setUISettings(coreStart.uiSettings); + + const getTmsService = jest.fn().mockReturnValue(({ + getVectorStyleSheet: () => ({ + version: 8, + sources: {}, + layers: [], + }), + getMaxZoom: async () => 20, + getMinZoom: async () => 0, + getAttributions: () => [{ url: 'tms_attributions' }], + } as unknown) as TMSService); + const config = { + tilemap: { + url: 'test', + options: { + attribution: 'tilemap-attribution', + minZoom: 0, + maxZoom: 20, + }, + }, + } as MapsLegacyConfig; + + function setMapService(defaultTmsLayer: string) { + setMapServiceSettings(({ + getTmsService, + defaultTmsLayer: () => defaultTmsLayer, + config, + } as unknown) as MapServiceSettings); + } + + async function createVegaMapView() { + await vegaParser.parseAsync(); + return new VegaMapView({ + vegaParser, + filterManager: dataPluginStart.query.filterManager, + timefilter: dataPluginStart.query.timefilter.timefilter, + fireEvent: (event: any) => {}, + parentEl: document.createElement('div'), + } as VegaViewParams); + } + + beforeEach(() => { + vegaParser = new VegaParser( + JSON.stringify(vegaMap), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }), + new TimeCache(dataPluginStart.query.timefilter.timefilter, 0), + {}, + mockGetServiceSettings + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should be added TmsRasterLayer and do not use tmsService if mapStyle is "user_configured"', async () => { + setMapService(userConfiguredLayerId); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: 'tilemap-attribution', + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).not.toHaveBeenCalled(); + expect(initTmsRasterLayer).toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should not be added TmsRasterLayer and use tmsService if mapStyle is not "user_configured"', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: [''], + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).toHaveBeenCalled(); + expect(initTmsRasterLayer).not.toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should be added NavigationControl', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + expect(NavigationControl).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts new file mode 100644 index 00000000000000..6a31eb0b378334 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Map, Style, NavigationControl, MapboxOptions } from 'mapbox-gl'; + +import { initTmsRasterLayer, initVegaLayer } from './layers'; +import { VegaBaseView } from '../vega_base_view'; +import { getMapServiceSettings } from '../../services'; +import { getAttributionsForTmsService } from './map_service_settings'; +import type { MapServiceSettings } from './map_service_settings'; + +import { + defaultMapConfig, + defaultMabBoxStyle, + userConfiguredLayerId, + vegaLayerId, +} from './constants'; + +import { validateZoomSettings, injectMapPropsIntoSpec } from './utils'; + +// @ts-expect-error +import { vega } from '../../lib/vega'; + +import './vega_map_view.scss'; + +async function updateVegaView(mapBoxInstance: Map, vegaView: vega.View) { + const mapCanvas = mapBoxInstance.getCanvas(); + const { lat, lng } = mapBoxInstance.getCenter(); + let shouldRender = false; + + const sendSignal = (sig: string, value: any) => { + if (vegaView.signal(sig) !== value) { + vegaView.signal(sig, value); + shouldRender = true; + } + }; + + sendSignal('width', mapCanvas.clientWidth); + sendSignal('height', mapCanvas.clientHeight); + sendSignal('latitude', lat); + sendSignal('longitude', lng); + sendSignal('zoom', mapBoxInstance.getZoom()); + + if (shouldRender) { + await vegaView.runAsync(); + } +} + +export class VegaMapView extends VegaBaseView { + private mapServiceSettings: MapServiceSettings = getMapServiceSettings(); + private mapStyle = this.getMapStyle(); + + private getMapStyle() { + const { mapStyle } = this._parser.mapConfig; + + return mapStyle === 'default' ? this.mapServiceSettings.defaultTmsLayer() : mapStyle; + } + + private get shouldShowZoomControl() { + return Boolean(this._parser.mapConfig.zoomControl); + } + + private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial { + const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig; + const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn); + + return { + ...zoomSettings, + center: [longitude, latitude], + scrollZoom: scrollWheelZoom, + }; + } + + private async initMapContainer(vegaView: vega.View) { + let style: Style = defaultMabBoxStyle; + let customAttribution: MapboxOptions['customAttribution'] = []; + const zoomSettings = { + minZoom: defaultMapConfig.minZoom, + maxZoom: defaultMapConfig.maxZoom, + }; + + if (this.mapStyle && this.mapStyle !== userConfiguredLayerId) { + const tmsService = await this.mapServiceSettings.getTmsService(this.mapStyle); + + if (!tmsService) { + this.onWarn( + i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { + defaultMessage: '{mapStyleParam} was not found', + values: { mapStyleParam: `"mapStyle":${this.mapStyle}` }, + }) + ); + return; + } + zoomSettings.maxZoom = (await tmsService.getMaxZoom()) ?? defaultMapConfig.maxZoom; + zoomSettings.minZoom = (await tmsService.getMinZoom()) ?? defaultMapConfig.minZoom; + customAttribution = getAttributionsForTmsService(tmsService); + style = (await tmsService.getVectorStyleSheet()) as Style; + } else { + customAttribution = this.mapServiceSettings.config.tilemap.options.attribution; + } + + // In some cases, Vega may be initialized twice, e.g. after awaiting... + if (!this._$container) return; + + const mapBoxInstance = new Map({ + style, + customAttribution, + container: this._$container.get(0), + ...this.getMapParams({ ...zoomSettings }), + }); + + const initMapComponents = () => { + this.initControls(mapBoxInstance); + this.initLayers(mapBoxInstance, vegaView); + + this._addDestroyHandler(() => { + if (mapBoxInstance.getLayer(vegaLayerId)) { + mapBoxInstance.removeLayer(vegaLayerId); + } + if (mapBoxInstance.getLayer(userConfiguredLayerId)) { + mapBoxInstance.removeLayer(userConfiguredLayerId); + } + mapBoxInstance.remove(); + }); + }; + + mapBoxInstance.once('load', initMapComponents); + } + + private initControls(mapBoxInstance: Map) { + if (this.shouldShowZoomControl) { + mapBoxInstance.addControl(new NavigationControl({ showCompass: false }), 'top-left'); + } + } + + private initLayers(mapBoxInstance: Map, vegaView: vega.View) { + const shouldShowUserConfiguredLayer = this.mapStyle === userConfiguredLayerId; + + if (shouldShowUserConfiguredLayer) { + const { url, options } = this.mapServiceSettings.config.tilemap; + + initTmsRasterLayer({ + id: userConfiguredLayerId, + map: mapBoxInstance, + context: { + tiles: [url!], + maxZoom: options.maxZoom ?? defaultMapConfig.maxZoom, + minZoom: options.minZoom ?? defaultMapConfig.minZoom, + tileSize: options.tileSize ?? defaultMapConfig.tileSize, + }, + }); + } + + initVegaLayer({ + id: vegaLayerId, + map: mapBoxInstance, + context: { + vegaView, + updateVegaView, + }, + }); + } + + protected async _initViewCustomizations() { + const vegaView = new vega.View( + vega.parse(injectMapPropsIntoSpec(this._parser.spec)), + this._vegaViewConfig + ); + + this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); + this.setView(vegaView); + + await this.initMapContainer(vegaView); + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_view.js index 8b6ebbe9c75942..2fd7e4fd606fd8 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_view.js @@ -16,8 +16,6 @@ export class VegaView extends VegaBaseView { const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig); - view.warn = this.onWarn.bind(this); - view.error = this.onError.bind(this); if (this._parser.useResize) this.updateVegaSize(view); view.initialize(this._$container.get(0), this._$controls.get(0)); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index af396dbf778d25..926c03e79bff98 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -10,13 +10,10 @@ import 'jest-canvas-mock'; import $ from 'jquery'; -import 'leaflet/dist/leaflet.js'; -import 'leaflet-vega'; import { createVegaVisualization } from './vega_visualization'; import vegaliteGraph from './test_utils/vegalite_graph.json'; import vegaGraph from './test_utils/vega_graph.json'; -import vegaMapGraph from './test_utils/vega_map_test.json'; import { VegaParser } from './data_model/vega_parser'; import { SearchAPI } from './data_model/search_api'; @@ -146,32 +143,5 @@ describe('VegaVisualizations', () => { vegaVis.destroy(); } }); - - test('should show vega blank rectangle on top of a map (vegamap)', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, jest.fn()); - const vegaParser = new VegaParser( - JSON.stringify(vegaMapGraph), - new SearchAPI({ - search: dataPluginStart.search, - uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, - }), - 0, - 0, - mockGetServiceSettings - ); - await vegaParser.parseAsync(); - - mockedWidthValue = 256; - mockedHeightValue = 256; - - await vegaVis.render(vegaParser); - expect(domNode.innerHTML).toMatchSnapshot(); - } finally { - vegaVis.destroy(); - } - }); }); }); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.ts b/src/plugins/vis_type_vega/public/vega_visualization.ts index 26647ecca93ec4..14dea362bc8c57 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.ts +++ b/src/plugins/vis_type_vega/public/vega_visualization.ts @@ -78,7 +78,7 @@ export const createVegaVisualization = ({ }; if (vegaParser.useMap) { - const { VegaMapView } = await import('./vega_view/vega_map_view'); + const { VegaMapView } = await import('./vega_view/vega_map_view/view'); this.vegaView = new VegaMapView(vegaViewParams); } else { const { VegaView: VegaViewClass } = await import('./vega_view/vega_view'); diff --git a/src/plugins/vis_type_vega/tsconfig.json b/src/plugins/vis_type_vega/tsconfig.json index e28839612bca7f..c013056ba4566f 100644 --- a/src/plugins/vis_type_vega/tsconfig.json +++ b/src/plugins/vis_type_vega/tsconfig.json @@ -10,7 +10,9 @@ "include": [ "server/**/*", "public/**/*", - "*.ts" + "*.ts", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/test_utils/vega_map_test.json" ], "references": [ { "path": "../../core/tsconfig.json" }, diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 56397351562de6..66941e201e9bad 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -269,3 +269,24 @@ } } } + +{ + "type": "doc", + "value": { + "id": "visualization:VegaMap", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "description": "VegaMap", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "title": "VegaMap", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[],\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega/v5.json\\n config: {\\n kibana: {type: \\\"map\\\", latitude: 25, longitude: -70, zoom: 3}\\n }\\n data: [\\n {\\n name: table\\n url: {\\n index: kibana_sample_data_flights\\n %context%: true\\n // Uncomment to enable time filtering\\n // %timefield%: timestamp\\n body: {\\n size: 0\\n aggs: {\\n origins: {\\n terms: {field: \\\"OriginAirportID\\\", size: 10000}\\n aggs: {\\n originLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"OriginLocation\\\", \\\"Origin\\\"]\\n }\\n }\\n }\\n distinations: {\\n terms: {field: \\\"DestAirportID\\\", size: 10000}\\n aggs: {\\n destLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"DestLocation\\\"]\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n format: {property: \\\"aggregations.origins.buckets\\\"}\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n originLocation.hits.hits[0]._source.OriginLocation.lon\\n originLocation.hits.hits[0]._source.OriginLocation.lat\\n ]\\n }\\n ]\\n }\\n {\\n name: selectedDatum\\n on: [\\n {trigger: \\\"!selected\\\", remove: true}\\n {trigger: \\\"selected\\\", insert: \\\"selected\\\"}\\n ]\\n }\\n ]\\n signals: [\\n {\\n name: selected\\n value: null\\n on: [\\n {events: \\\"@airport:mouseover\\\", update: \\\"datum\\\"}\\n {events: \\\"@airport:mouseout\\\", update: \\\"null\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: airportSize\\n type: linear\\n domain: {data: \\\"table\\\", field: \\\"doc_count\\\"}\\n range: [\\n {signal: \\\"zoom*zoom*0.2+1\\\"}\\n {signal: \\\"zoom*zoom*10+1\\\"}\\n ]\\n }\\n ]\\n marks: [\\n {\\n type: group\\n from: {\\n facet: {\\n name: facetedDatum\\n data: selectedDatum\\n field: distinations.buckets\\n }\\n }\\n data: [\\n {\\n name: facetDatumElems\\n source: facetedDatum\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n destLocation.hits.hits[0]._source.DestLocation.lon\\n destLocation.hits.hits[0]._source.DestLocation.lat\\n ]\\n }\\n {type: \\\"formula\\\", expr: \\\"{x:parent.x, y:parent.y}\\\", as: \\\"source\\\"}\\n {type: \\\"formula\\\", expr: \\\"{x:datum.x, y:datum.y}\\\", as: \\\"target\\\"}\\n {type: \\\"linkpath\\\", shape: \\\"diagonal\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: lineThickness\\n type: log\\n clamp: true\\n range: [1, 8]\\n }\\n {\\n name: lineOpacity\\n type: log\\n clamp: true\\n range: [0.2, 0.8]\\n }\\n ]\\n marks: [\\n {\\n from: {data: \\\"facetDatumElems\\\"}\\n type: path\\n interactive: false\\n encode: {\\n update: {\\n path: {field: \\\"path\\\"}\\n stroke: {value: \\\"black\\\"}\\n strokeWidth: {scale: \\\"lineThickness\\\", field: \\\"doc_count\\\"}\\n strokeOpacity: {scale: \\\"lineOpacity\\\", field: \\\"doc_count\\\"}\\n }\\n }\\n }\\n ]\\n }\\n {\\n name: airport\\n type: symbol\\n from: {data: \\\"table\\\"}\\n encode: {\\n update: {\\n size: {scale: \\\"airportSize\\\", field: \\\"doc_count\\\"}\\n xc: {signal: \\\"datum.x\\\"}\\n yc: {signal: \\\"datum.y\\\"}\\n tooltip: {\\n signal: \\\"{title: datum.originLocation.hits.hits[0]._source.Origin + ' (' + datum.key + ')', connnections: length(datum.distinations.buckets), flights: datum.doc_count}\\\"\\n }\\n }\\n }\\n }\\n ]\\n}\"},\"title\":\"[Flights] Airport Connections (Hover Over Airport)\",\"type\":\"vega\"}" + } + } + } +} diff --git a/test/visual_regression/config.ts b/test/visual_regression/config.ts index c4951760fc7563..60219efc61e6ca 100644 --- a/test/visual_regression/config.ts +++ b/test/visual_regression/config.ts @@ -15,7 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), - testFiles: [require.resolve('./tests/console_app'), require.resolve('./tests/discover')], + testFiles: [ + require.resolve('./tests/console_app'), + require.resolve('./tests/discover'), + require.resolve('./tests/vega'), + ], services, diff --git a/test/visual_regression/tests/vega/index.ts b/test/visual_regression/tests/vega/index.ts new file mode 100644 index 00000000000000..6f79ee834b3dc0 --- /dev/null +++ b/test/visual_regression/tests/vega/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// Width must be the same as visual_testing or canvas image widths will get skewed +const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || []; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + + describe('vega app', function () { + this.tags('ciGroup6'); + + before(function () { + return browser.setWindowSize(SCREEN_WIDTH, 1000); + }); + + loadTestFile(require.resolve('./vega_map_visualization')); + }); +} diff --git a/test/visual_regression/tests/vega/vega_map_visualization.ts b/test/visual_regression/tests/vega/vega_map_visualization.ts new file mode 100644 index 00000000000000..98aad0cb877956 --- /dev/null +++ b/test/visual_regression/tests/vega/vega_map_visualization.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'visualize', 'visChart', 'visEditor', 'vegaChart']); + const visualTesting = getService('visualTesting'); + + describe('vega chart in visualize app', () => { + before(async () => { + await esArchiver.loadIfNeeded('kibana_sample_data_flights'); + await esArchiver.loadIfNeeded('visualize'); + }); + + after(async () => { + await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('visualize'); + }); + + it('should show map with vega layer', async function () { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.openSavedVisualization('VegaMap'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await visualTesting.snapshot(); + }); + }); +} From a29d4d3b1b41f7159621c1666cf9d604b9f09d5c Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 29 Jan 2021 07:10:12 -0700 Subject: [PATCH 04/38] Fix flights sample data dashboard visualization (#89460) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/apps/discover/_data_grid_doc_table.ts | 5 +++++ test/functional/apps/home/_sample_data.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 8481065c18466c..10cdd7e866af9b 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -33,6 +33,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); }); + after(async function () { + log.debug('reset uiSettings'); + await kibanaServer.uiSettings.replace({}); + }); + it('should show the first 50 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await dataGrid.getDocTableRows(); diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 438dd6f8adce20..a9fe2026112b69 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -20,8 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard', 'timePicker']); - // Failing: See https://github.com/elastic/kibana/issues/89379 - describe.skip('sample data', function describeIndexTests() { + describe('sample data', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { From 5c45e7dfcfe1f2e43171162a2962f4473e399f83 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 29 Jan 2021 07:48:51 -0700 Subject: [PATCH 05/38] Migrates watcher to a TS project ref (#89622) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/jest_constants.ts | 2 +- x-pack/plugins/watcher/tsconfig.json | 29 +++++++++++++++++++ x-pack/test/tsconfig.json | 3 +- x-pack/tsconfig.json | 2 ++ x-pack/tsconfig.refs.json | 3 +- 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/watcher/tsconfig.json diff --git a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts index 6f243e130c2356..7b4876f542292f 100644 --- a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts +++ b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts @@ -8,4 +8,4 @@ import { getWatch } from '../../__fixtures__'; export const WATCH_ID = 'my-test-watch'; -export const WATCH = { watch: getWatch({ id: WATCH_ID }) }; +export const WATCH: any = { watch: getWatch({ id: WATCH_ID }) }; diff --git a/x-pack/plugins/watcher/tsconfig.json b/x-pack/plugins/watcher/tsconfig.json new file mode 100644 index 00000000000000..4680847ba486d4 --- /dev/null +++ b/x-pack/plugins/watcher/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "public/**/*", + "common/**/*", + "tests_client_integration/**/*", + "__fixtures__/*", + "../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6a75f0c7e02d36..cc36a2c93b1a03 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -59,6 +59,7 @@ { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/saved_objects_tagging/tsconfig.json" }, { "path": "../plugins/global_search_bar/tsconfig.json" }, - { "path": "../plugins/license_management/tsconfig.json" } + { "path": "../plugins/license_management/tsconfig.json" }, + { "path": "../plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 7ed53ca0abb6bf..956bd409f979de 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -38,6 +38,7 @@ "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", "plugins/license_management/**/*", + "plugins/watcher/**/*", "test/**/*" ], "compilerOptions": { @@ -109,5 +110,6 @@ { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index eeba8dd770da6a..1724cb2afbffaa 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -32,6 +32,7 @@ { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/license_management/tsconfig.json" } + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" } ] } From 1fc45a7c370b38fe2a3ad0727f956f79d922b5b3 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Fri, 29 Jan 2021 09:59:05 -0500 Subject: [PATCH 06/38] Fix Lens Save and Return Removing Tags (#89613) * use last saved tag ids in save and return... --- x-pack/plugins/lens/public/app_plugin/app.tsx | 17 +-- x-pack/test/functional/apps/lens/index.ts | 1 + .../test/functional/apps/lens/lens_tagging.ts | 118 ++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 x-pack/test/functional/apps/lens/lens_tagging.ts diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index c7764684029c72..2dcda656c779b0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -370,6 +370,11 @@ export function App({ state.persistedDoc?.state, ]); + const tagsIds = + state.persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) + : []; + const runSave = async ( saveProps: Omit & { returnToOrigin: boolean; @@ -385,8 +390,11 @@ export function App({ } let references = lastKnownDoc.references; - if (savedObjectsTagging && saveProps.newTags) { - references = savedObjectsTagging.ui.updateTagsReferences(references, saveProps.newTags); + if (savedObjectsTagging) { + references = savedObjectsTagging.ui.updateTagsReferences( + references, + saveProps.newTags || tagsIds + ); } const docToSave = { @@ -586,11 +594,6 @@ export function App({ }, }); - const tagsIds = - state.persistedDoc && savedObjectsTagging - ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) - : []; - return ( <>
    diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 642526d74b6874..db8ede58ca9d45 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -34,6 +34,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./chart_data')); loadTestFile(require.resolve('./drag_and_drop')); loadTestFile(require.resolve('./lens_reporting')); + loadTestFile(require.resolve('./lens_tagging')); // has to be last one in the suite because it overrides saved objects loadTestFile(require.resolve('./rollup')); diff --git a/x-pack/test/functional/apps/lens/lens_tagging.ts b/x-pack/test/functional/apps/lens/lens_tagging.ts new file mode 100644 index 00000000000000..970eaa89548d2b --- /dev/null +++ b/x-pack/test/functional/apps/lens/lens_tagging.ts @@ -0,0 +1,118 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const retry = getService('retry'); + const find = getService('find'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const PageObjects = getPageObjects([ + 'common', + 'tagManagement', + 'header', + 'dashboard', + 'visualize', + 'lens', + ]); + + const lensTag = 'extreme-lens-tag'; + const lensTitle = 'lens tag test'; + + describe('lens tagging', () => { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + it('adds a new tag to a Lens visualization', async () => { + // create lens + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickLensWidget(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsApp_saveButton'); + + await PageObjects.visualize.setSaveModalValues(lensTitle, { + saveAsNew: false, + redirectToOrigin: true, + }); + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(true); + + await PageObjects.tagManagement.tagModal.fillForm( + { + name: lensTag, + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(false); + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); + }); + + it('retains its saved object tags after save and return', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.waitUntilTableIsLoaded(); + + // open the filter dropdown + const filterButton = await find.byCssSelector('.euiFilterGroup .euiFilterButton'); + await filterButton.click(); + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(lensTag)}` + ); + // click elsewhere to close the filter dropdown + const searchFilter = await find.byCssSelector('main .euiFieldSearch'); + await searchFilter.click(); + // wait until the table refreshes + await listingTable.waitUntilTableIsLoaded(); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain(lensTitle); + }); + }); +} From f732b2c3c5627ab17521f60598e3097d542cded4 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 29 Jan 2021 15:59:17 +0100 Subject: [PATCH 07/38] Fix rendering of Saved object indices and aliases per {kib} version table (#89700) Collapsing rows and applying vertical alignment doesn't work well when published to the docs website. So I removed the last column. --- docs/setup/upgrade/upgrade-migrations.asciidoc | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index 7436536d227817..cc6e3638728086 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -19,17 +19,16 @@ Saved objects are stored in two indices: * `.kibana_{kibana_version}_001`, or if the `kibana.index` configuration setting is set `.{kibana.index}_{kibana_version}_001`. E.g. for Kibana v7.12.0 `.kibana_7.12.0_001`. * `.kibana_task_manager_{kibana_version}_001`, or if the `xpack.tasks.index` configuration setting is set `.{xpack.tasks.index}_{kibana_version}_001` E.g. for Kibana v7.12.0 `.kibana_task_manager_7.12.0_001`. -The index aliases `.kibana` and `.kibana_task_manager` will always point to the most up-to-date version indices. +The index aliases `.kibana` and `.kibana_task_manager` will always point to +the most up-to-date saved object indices. The first time a newer {kib} starts, it will first perform an upgrade migration before starting plugins or serving HTTP traffic. To prevent losing acknowledged writes old nodes should be shutdown before starting the upgrade. To reduce the likelihood of old nodes losing acknowledged writes, {kib} 7.12.0 and later will add a write block to the outdated index. Table 1 lists the saved objects indices used by previous versions of {kib}. .Saved object indices and aliases per {kib} version [options="header"] -[cols="a,a,a"] |======================= -|Upgrading from version | Outdated index (alias) | Upgraded index (alias) -| 6.0.0 through 6.4.x | `.kibana` 1.3+^.^| `.kibana_7.12.0_001` -(`.kibana` alias) +|Upgrading from version | Outdated index (alias) +| 6.0.0 through 6.4.x | `.kibana` `.kibana_task_manager_7.12.0_001` (`.kibana_task_manager` alias) | 6.5.0 through 7.3.x | `.kibana_N` (`.kibana` alias) From e7cbdd3050d65c8e875e7baeac25983cfbb7b733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 29 Jan 2021 15:00:39 +0000 Subject: [PATCH 08/38] @kbn/telemetry-tools: Better CI error message (#89688) --- .../src/tools/tasks/check_matching_schemas_task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts index b6dcd40b53d2ed..08bfa5eb404ca6 100644 --- a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts +++ b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts @@ -23,7 +23,7 @@ export function checkMatchingSchemasTask({ roots }: TaskContext, throwOnDiff: bo root.esMappingDiffs = Object.keys(differences); if (root.esMappingDiffs.length && throwOnDiff) { throw Error( - `The following changes must be persisted in ${fullPath} file. Use '--fix' to update.\n${JSON.stringify( + `The following changes must be persisted in ${fullPath} file. Run 'node scripts/telemetry_check --fix' to update.\n${JSON.stringify( differences, null, 2 From a08895dbfc20ea883afb526a8597014ded4571b7 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 29 Jan 2021 10:42:35 -0500 Subject: [PATCH 09/38] [ML] Anomaly Detection: add anomalies map to explorer for jobs with 'lat_long' function (#88416) * wip: create embedded map component for explorer * add embeddedMap component to explorer * use geo_results * remove charts callout when map is shown * add translation, round geo coordinates * create GEO_MAP chart type and move embedded map to charts area * remove embedded map that is no longer used * fix type and fail silently if plugin not available * fix multiple type of jobs charts view * fix tooltip function and remove single viewer link for latlong * ensure diff types of jobs show correct charts. fix jest test * show errorCallout if maps not enabled and is lat_long job * use shared MlEmbeddedMapComponent in explorer * ensure latLong jobs not viewable in single metric viewer * update jest test --- x-pack/plugins/ml/common/util/job_utils.ts | 12 ++ .../ml_embedded_map/ml_embedded_map.tsx | 11 +- .../explorer_chart_embedded_map.tsx | 33 ++++ .../explorer_charts_container.js | 76 +++++++-- .../explorer_charts_container_service.js | 111 +++++++++--- .../explorer/explorer_charts/map_config.ts | 161 ++++++++++++++++++ .../explorer/explorer_constants.ts | 1 + .../application/explorer/explorer_utils.js | 1 + .../advanced_detector_modal/descriptions.tsx | 2 +- .../results_service/result_service_rx.ts | 3 +- .../application/util/chart_config_builder.js | 5 +- .../ml/public/application/util/chart_utils.js | 5 + 12 files changed, 364 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx create mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 4f4d9851c49578..d20ad4a3689489 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -78,6 +78,18 @@ export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number ); } +// Returns a flag to indicate whether the specified job is suitable for embedded map viewing. +export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean { + let isMappable = false; + const { detectors } = job.analysis_config; + if (detectorIndex >= 0 && detectorIndex < detectors.length) { + const dtr = detectors[detectorIndex]; + const functionName = dtr.function; + isMappable = functionName === ML_JOB_AGGREGATION.LAT_LONG; + } + return isMappable; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx index d5fdc9d52a1028..12c7d6ac69bb11 100644 --- a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { htmlIdGenerator } from '@elastic/eui'; import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { INITIAL_LOCATION } from '../../../../../maps/common/constants'; import { MapEmbeddable, MapEmbeddableInput, @@ -81,21 +82,13 @@ export function MlEmbeddedMapComponent({ viewMode: ViewMode.VIEW, isLayerTOCOpen: false, hideFilterActions: true, - // Zoom Lat/Lon values are set to make sure map is in center in the panel - // It will also omit Greenland/Antarctica etc. NOTE: Can be removed when initialLocation is set - mapCenter: { - lon: 11, - lat: 20, - zoom: 1, - }, // can use mapSettings to center map on anomalies mapSettings: { disableInteractive: false, hideToolbarOverlay: false, hideLayerControl: false, hideViewControl: false, - // Doesn't currently work with GEO_JSON. Will uncomment when https://github.com/elastic/kibana/pull/88294 is in - // initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query }, }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx new file mode 100644 index 00000000000000..fc1621e962f36c --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx @@ -0,0 +1,33 @@ +/* + * 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, useEffect } from 'react'; +import { Dictionary } from '../../../../common/types/common'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { getMLAnomaliesActualLayer, getMLAnomaliesTypicalLayer } from './map_config'; +import { MlEmbeddedMapComponent } from '../../components/ml_embedded_map'; +interface Props { + seriesConfig: Dictionary; +} + +export function EmbeddedMapComponentWrapper({ seriesConfig }: Props) { + const [layerList, setLayerList] = useState([]); + + useEffect(() => { + if (seriesConfig.mapData && seriesConfig.mapData.length > 0) { + setLayerList([ + getMLAnomaliesActualLayer(seriesConfig.mapData), + getMLAnomaliesTypicalLayer(seriesConfig.mapData), + ]); + } + }, [seriesConfig]); + + return ( +
    + +
    + ); +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 774372f678c9b1..9921b5f9918440 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -22,6 +22,7 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -30,6 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; @@ -43,6 +45,9 @@ const textViewButton = i18n.translate( defaultMessage: 'Open in Single Metric Viewer', } ); +const mapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.mapsPluginMissingMessage', { + defaultMessage: 'maps or embeddable start plugin not found', +}); // create a somewhat unique ID // from charts metadata for React's key attribute @@ -67,8 +72,8 @@ function ExplorerChartContainer({ useEffect(() => { let isCancelled = false; const generateLink = async () => { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); - if (!isCancelled) { + if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -150,6 +155,18 @@ function ExplorerChartContainer({ {(() => { + if (chartType === CHART_TYPE.GEO_MAP) { + return ( + + {(tooltipService) => ( + + )} + + ); + } if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -167,18 +184,20 @@ function ExplorerChartContainer({ ); } - return ( - - {(tooltipService) => ( - - )} - - ); + if (chartType === CHART_TYPE.SINGLE_METRIC) { + return ( + + {(tooltipService) => ( + + )} + + ); + } })()} ); @@ -199,8 +218,31 @@ export const ExplorerChartsContainerUI = ({ share: { urlGenerators: { getUrlGenerator }, }, + embeddable: embeddablePlugin, + maps: mapsPlugin, }, } = kibana; + + let seriesToPlotFiltered; + + if (!embeddablePlugin || !mapsPlugin) { + seriesToPlotFiltered = []; + // Show missing plugin callout + seriesToPlot.forEach((series) => { + if (series.functionDescription === 'lat_long') { + if (errorMessages[mapsPluginMessage] === undefined) { + errorMessages[mapsPluginMessage] = new Set([series.jobId]); + } else { + errorMessages[mapsPluginMessage].add(series.jobId); + } + } else { + seriesToPlotFiltered.push(series); + } + }); + } + + const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. @@ -208,13 +250,13 @@ export const ExplorerChartsContainerUI = ({ const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series)); + const wrapLabel = seriesToUse.some((series) => isLabelLengthAboveThreshold(series)); return ( <> - {seriesToPlot.length > 0 && - seriesToPlot.map((series) => ( + {seriesToUse.length > 0 && + seriesToUse.map((series) => ( + (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map(buildConfig); + const seriesConfigsNoGeoData = []; + + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. data.tooManyBuckets = false; @@ -92,13 +137,6 @@ export const anomalyDataChange = function ( ); data.tooManyBuckets = tooManyBuckets; - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - data.errorMessages = errorMessages; explorerService.setCharts({ ...data }); @@ -269,22 +307,27 @@ export const anomalyDataChange = function ( // only after that trigger data processing and page render. // TODO - if query returns no results e.g. source data has been deleted, // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map((seriesConfig) => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); + const seriesPromises = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesCongifsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + }); function processChartData(response, seriesIndex) { const metricData = response[0].results; const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; + const jobId = seriesCongifsForPromises[seriesIndex].jobId; const scheduledEvents = response[2].events[jobId]; const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); + const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); // Sort records in ascending time order matching up with chart data records.sort((recordA, recordB) => { @@ -409,16 +452,25 @@ export const anomalyDataChange = function ( ); const overallChartLimits = chartLimits(allDataPoints); - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), - })); + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesCongifsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } explorerService.setCharts({ ...data }); }) .catch((error) => { @@ -447,7 +499,10 @@ function processRecordsForDisplay(anomalyRecords) { return; } - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + if (isChartable === false) { if (isModelPlotChartableForDetector(job, record.detector_index)) { // Check if model plot is enabled for this job. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts new file mode 100644 index 00000000000000..451fa602315d79 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -0,0 +1,161 @@ +/* + * 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 { FIELD_ORIGIN, STYLE_TYPE } from '../../../../../maps/common/constants'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; + +const FEATURE = 'Feature'; +const POINT = 'Point'; +const SEVERITY_COLOR_RAMP = [ + { + stop: ANOMALY_THRESHOLD.LOW, + color: SEVERITY_COLORS.WARNING, + }, + { + stop: ANOMALY_THRESHOLD.MINOR, + color: SEVERITY_COLORS.MINOR, + }, + { + stop: ANOMALY_THRESHOLD.MAJOR, + color: SEVERITY_COLORS.MAJOR, + }, + { + stop: ANOMALY_THRESHOLD.CRITICAL, + color: SEVERITY_COLORS.CRITICAL, + }, +]; + +function getAnomalyFeatures(anomalies: any[], type: 'actual_point' | 'typical_point') { + const anomalyFeatures = []; + for (let i = 0; i < anomalies.length; i++) { + const anomaly = anomalies[i]; + const geoResults = anomaly.geo_results || (anomaly?.causes && anomaly?.causes[0]?.geo_results); + const coordinateStr = geoResults && geoResults[type]; + if (coordinateStr !== undefined) { + // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs + const coordinates = coordinateStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + + anomalyFeatures.push({ + type: FEATURE, + geometry: { + type: POINT, + coordinates, + }, + properties: { + record_score: Math.floor(anomaly.record_score), + [type]: coordinates.map((point: number) => point.toFixed(2)), + }, + }); + } + } + return anomalyFeatures; +} + +export const getMLAnomaliesTypicalLayer = (anomalies: any) => { + return { + id: 'anomalies_typical_layer', + label: 'Typical', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854e', + type: 'GEOJSON_FILE', + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'typical_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: '#98A2B2', + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; + +export const getMLAnomaliesActualLayer = (anomalies: any) => { + return { + id: 'anomalies_actual_layer', + label: 'Actual', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854d', + type: 'GEOJSON_FILE', + __fields: [ + { + name: 'record_score', + type: 'number', + }, + ], + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'actual_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + customColorRamp: SEVERITY_COLOR_RAMP, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: true, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 3f5f016fc365a6..2178c837458e92 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -48,6 +48,7 @@ export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', + GEO_MAP: 'geo_map', }; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index f6889c9a6f24c2..4ba9d4ea14f10a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -511,6 +511,7 @@ export async function loadAnomaliesTableData( const entityFields = getEntityFieldList(anomaly.source); isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); } + anomaly.isTimeSeriesViewRecord = isChartable; if (mlJobService.customUrlsByJob[jobId] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx index 280ac85a5a2bc2..470fe11759d271 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx @@ -46,7 +46,7 @@ export const FieldDescription: FC = memo(({ children }) => { description={ } > diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 514449385bf0b5..3747e84f437653 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -156,7 +156,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { } body.aggs.byTime.aggs = {}; - if (metricFieldName !== undefined && metricFieldName !== '') { + + if (metricFieldName !== undefined && metricFieldName !== '' && metricFunction) { const metricAgg: any = { [metricFunction]: {}, }; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index a30280f1220c03..a306211defc87e 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -24,7 +24,10 @@ export function buildConfigFromDetector(job, detectorIndex) { const config = { jobId: job.job_id, detectorIndex: detectorIndex, - metricFunction: mlFunctionToESAggregation(detector.function), + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), timeField: job.data_description.time_field, interval: job.analysis_config.bucket_span, datafeedConfig: job.datafeed_config, diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 402c922a0034ff..799187cc37dfdc 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -176,6 +176,11 @@ const POPULATION_DISTRIBUTION_ENABLED = true; // get the chart type based on its configuration export function getChartType(config) { let chartType = CHART_TYPE.SINGLE_METRIC; + + if (config.functionDescription === 'lat_long' || config.mapData !== undefined) { + return CHART_TYPE.GEO_MAP; + } + if ( EVENT_DISTRIBUTION_ENABLED && config.functionDescription === 'rare' && From 98b80484b5e22463b4e421ae5353281fd424e022 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 29 Jan 2021 09:47:15 -0700 Subject: [PATCH 10/38] Converts painlessLab to a TS project reference (#89626) --- x-pack/plugins/painless_lab/tsconfig.json | 23 +++++++++++++++++++++++ x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 2 ++ x-pack/tsconfig.refs.json | 1 + 4 files changed, 27 insertions(+) create mode 100644 x-pack/plugins/painless_lab/tsconfig.json diff --git a/x-pack/plugins/painless_lab/tsconfig.json b/x-pack/plugins/painless_lab/tsconfig.json new file mode 100644 index 00000000000000..a869b21e06d4d5 --- /dev/null +++ b/x-pack/plugins/painless_lab/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index cc36a2c93b1a03..461ebfe15b1090 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -60,6 +60,7 @@ { "path": "../plugins/saved_objects_tagging/tsconfig.json" }, { "path": "../plugins/global_search_bar/tsconfig.json" }, { "path": "../plugins/license_management/tsconfig.json" }, + { "path": "../plugins/painless_lab/tsconfig.json" }, { "path": "../plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 956bd409f979de..d64b17813f6606 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -38,6 +38,7 @@ "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", "plugins/license_management/**/*", + "plugins/painless_lab/**/*", "plugins/watcher/**/*", "test/**/*" ], @@ -110,6 +111,7 @@ { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, { "path": "./plugins/watcher/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 1724cb2afbffaa..694d359b6a05d0 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -33,6 +33,7 @@ { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, { "path": "./plugins/watcher/tsconfig.json" } ] } From d7b1cbbed5bc74175f4b81ae56a08377eb92cd20 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Fri, 29 Jan 2021 10:01:35 -0700 Subject: [PATCH 11/38] [data.search.searchSource] Add fetch$ observable for partial results (#89211) * [data.search.searchSource] Add fetch$ observable for partial results * Fix mocks & add tests * Update docs * Update docs * Review feedback --- ...-plugins-data-public.searchsource.fetch.md | 6 +- ...plugins-data-public.searchsource.fetch_.md | 24 ++++++++ ...plugin-plugins-data-public.searchsource.md | 1 + .../data/common/search/search_source/mocks.ts | 3 +- .../search_source/search_source.test.ts | 37 +++++++++++- .../search/search_source/search_source.ts | 58 ++++++++++--------- src/plugins/data/public/public.api.md | 4 +- 7 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index 8fd17e6b1a1d95..e96fe8b8e08dc6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -4,8 +4,12 @@ ## SearchSource.fetch() method -Fetch this source and reject the returned Promise on error +> Warning: This API is now obsolete. +> +> Use fetch$ instead +> +Fetch this source and reject the returned Promise on error Signature: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md new file mode 100644 index 00000000000000..bcf220a9a27e62 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) > [fetch$](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) + +## SearchSource.fetch$() method + +Fetch this source from Elasticsearch, returning an observable over the response(s) + +Signature: + +```typescript +fetch$(options?: ISearchOptions): import("rxjs").Observable>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | ISearchOptions | | + +Returns: + +`import("rxjs").Observable>` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md index df302e9f3b0d39..2af9cc14e36689 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md @@ -33,6 +33,7 @@ export declare class SearchSource | [createCopy()](./kibana-plugin-plugins-data-public.searchsource.createcopy.md) | | creates a copy of this search source (without its children) | | [destroy()](./kibana-plugin-plugins-data-public.searchsource.destroy.md) | | Completely destroy the SearchSource. {undefined} | | [fetch(options)](./kibana-plugin-plugins-data-public.searchsource.fetch.md) | | Fetch this source and reject the returned Promise on error | +| [fetch$(options)](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) | | Fetch this source from Elasticsearch, returning an observable over the response(s) | | [getField(field, recurse)](./kibana-plugin-plugins-data-public.searchsource.getfield.md) | | Gets a single field from the fields | | [getFields()](./kibana-plugin-plugins-data-public.searchsource.getfields.md) | | returns all search source fields | | [getId()](./kibana-plugin-plugins-data-public.searchsource.getid.md) | | returns search source id | diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index 328f05fac8594a..08fe2b07096bb7 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import type { MockedKeys } from '@kbn/utility-types/jest'; import { uiSettingsServiceMock } from '../../../../../core/public/mocks'; @@ -27,6 +27,7 @@ export const searchSourceInstanceMock: MockedKeys = { createChild: jest.fn().mockReturnThis(), setParent: jest.fn(), getParent: jest.fn().mockReturnThis(), + fetch$: jest.fn().mockReturnValue(of({})), fetch: jest.fn().mockResolvedValue({}), onRequestStart: jest.fn(), getSearchRequestBody: jest.fn(), diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 6d7654c6659f23..c2a4beb9b61a52 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -51,7 +51,14 @@ describe('SearchSource', () => { let searchSource: SearchSource; beforeEach(() => { - mockSearchMethod = jest.fn().mockReturnValue(of({ rawResponse: '' })); + mockSearchMethod = jest + .fn() + .mockReturnValue( + of( + { rawResponse: { isPartial: true, isRunning: true } }, + { rawResponse: { isPartial: false, isRunning: false } } + ) + ); searchSourceDependencies = { getConfig: jest.fn(), @@ -564,6 +571,34 @@ describe('SearchSource', () => { await searchSource.fetch(options); expect(mockSearchMethod).toBeCalledTimes(1); }); + + test('should return partial results', (done) => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = () => { + expect(next).toBeCalledTimes(2); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": true, + "isRunning": true, + }, + ] + `); + expect(next.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": false, + "isRunning": false, + }, + ] + `); + done(); + }; + searchSource.fetch$(options).subscribe({ next, complete }); + }); }); describe('#serialize', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 554e8385881f23..bb60f0d7b4ad48 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,7 +60,8 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash'; -import { map } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { defer, from } from 'rxjs'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern } from '../../index_patterns'; @@ -244,30 +245,35 @@ export class SearchSource { } /** - * Fetch this source and reject the returned Promise on error - * - * @async + * Fetch this source from Elasticsearch, returning an observable over the response(s) + * @param options */ - async fetch(options: ISearchOptions = {}) { + fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; - await this.requestIsStarting(options); - - const searchRequest = await this.flatten(); - this.history = [searchRequest]; - - let response; - if (getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)) { - response = await this.legacyFetch(searchRequest, options); - } else { - response = await this.fetchSearch(searchRequest, options); - } - - // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved - if ((response as any).error) { - throw new RequestFailure(null, response); - } + return defer(() => this.requestIsStarting(options)).pipe( + switchMap(() => { + const searchRequest = this.flatten(); + this.history = [searchRequest]; + + return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES) + ? from(this.legacyFetch(searchRequest, options)) + : this.fetchSearch$(searchRequest, options); + }), + tap((response) => { + // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved + if ((response as any).error) { + throw new RequestFailure(null, response); + } + }) + ); + } - return response; + /** + * Fetch this source and reject the returned Promise on error + * @deprecated Use fetch$ instead + */ + fetch(options: ISearchOptions = {}) { + return this.fetch$(options).toPromise(); } /** @@ -305,16 +311,16 @@ export class SearchSource { * Run a search using the search service * @return {Promise>} */ - private fetchSearch(searchRequest: SearchRequest, options: ISearchOptions) { + private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) { const { search, getConfig, onResponse } = this.dependencies; const params = getSearchParamsFromRequest(searchRequest, { getConfig, }); - return search({ params, indexType: searchRequest.indexType }, options) - .pipe(map(({ rawResponse }) => onResponse(searchRequest, rawResponse))) - .toPromise(); + return search({ params, indexType: searchRequest.indexType }, options).pipe( + map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + ); } /** diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 9e493f46b0781b..5b1462e5d506b3 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2360,6 +2360,8 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; + fetch$(options?: ISearchOptions): import("rxjs").Observable>; + // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): { @@ -2601,7 +2603,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/search/search_source/search_source.ts:186:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/search/search_source/search_source.ts:187:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts From 61d4d870e28bb99f7cad88cbef71fa5a6b32ccf6 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 29 Jan 2021 19:19:19 +0200 Subject: [PATCH 12/38] [Security Solution][Case] Allow users with Gold license to use Jira (#89406) --- .../case/common/api/cases/configure.ts | 3 +- .../routes/api/__fixtures__/route_contexts.ts | 4 +- .../routes/api/__mocks__/request_responses.ts | 44 ++++++++ .../cases/configure/get_connectors.test.ts | 62 +++++++++++ .../api/cases/configure/get_connectors.ts | 17 ++- .../cases/components/all_cases/index.test.tsx | 63 +++++++++++ .../configure_cases/__mock__/index.tsx | 11 +- .../components/configure_cases/button.tsx | 2 + .../components/configure_cases/index.test.tsx | 27 ++++- .../components/configure_cases/index.tsx | 22 ++-- .../use_push_to_service/helpers.tsx | 11 +- .../use_push_to_service/translations.ts | 9 +- .../containers/configure/__mocks__/api.ts | 6 +- .../cases/containers/configure/api.test.ts | 29 ++++- .../public/cases/containers/configure/api.ts | 11 ++ .../public/cases/containers/configure/mock.ts | 45 ++++++++ .../cases/containers/configure/types.ts | 11 +- .../configure/use_action_types.test.tsx | 101 ++++++++++++++++++ .../containers/configure/use_action_types.tsx | 72 +++++++++++++ .../public/cases/containers/mock.ts | 7 ++ .../use_get_action_license.test.tsx | 2 +- .../containers/use_get_action_license.tsx | 5 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 24 files changed, 539 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index b82c6de8fc363c..398f73f2721a65 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -6,11 +6,12 @@ import * as rt from 'io-ts'; -import { ActionResult } from '../../../../actions/common'; +import { ActionResult, ActionType } from '../../../../actions/common'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; export type ActionConnector = ActionResult; +export type ActionTypeConnector = ActionType; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 40911496d6494a..3a12b50cf8f68b 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -14,13 +14,15 @@ import { CaseConfigureService, ConnectorMappingsService, } from '../../../services'; -import { getActions } from '../__mocks__/request_responses'; +import { getActions, getActionTypes } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; import type { CasesRequestHandlerContext } from '../../../types'; export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index efc3b6044a8045..236deb9c7462cd 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { + ActionTypeConnector, CasePostRequest, CasesConfigureRequest, ConnectorTypes, @@ -73,6 +74,49 @@ export const getActions = (): FindActionResult[] => [ }, ]; +export const getActionTypes = (): ActionTypeConnector[] => [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const newConfiguration: CasesConfigureRequest = { connector: { id: '456', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index b744a6dc048107..974ae9283dd98a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -42,10 +42,72 @@ describe('GET connectors', () => { expect(res.status).toEqual(200); const expected = getActions(); + // The first connector returned by getActions is of type .webhook and we expect to be filtered expected.shift(); expect(res.payload).toEqual(expected); }); + it('filters out connectors that are not enabled in license', async () => { + const req = httpServerMock.createKibanaRequest({ + path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, + method: 'get', + }); + + const context = await createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + (actionsClient.listTypes as jest.Mock).mockImplementation(() => + Promise.resolve([ + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + ]) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual([ + { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, + ]); + }); + it('it throws an error when actions client is null', async () => { const req = httpServerMock.createKibanaRequest({ path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index cb88f04a9b835c..cf854df9f04f29 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; +import { ActionType } from '../../../../../../actions/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../../actions/server/types'; @@ -17,10 +18,13 @@ import { RESILIENT_ACTION_TYPE_ID, } from '../../../../../common/constants'; -const isConnectorSupported = (action: FindActionResult): boolean => +const isConnectorSupported = ( + action: FindActionResult, + actionTypes: Record +): boolean => [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( action.actionTypeId - ); + ) && actionTypes[action.actionTypeId]?.enabledInLicense; /* * Be aware that this api will only return 20 connectors @@ -40,7 +44,14 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter(isConnectorSupported); + const actionTypes = (await actionsClient.listTypes()).reduce( + (types, type) => ({ ...types, [type.id]: type }), + {} + ); + + const results = (await actionsClient.getAll()).filter((action) => + isConnectorSupported(action, actionTypes) + ); return response.ok({ body: results }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 71fd74570c16af..009053067064a6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -20,6 +20,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; +import { useGetActionLicense } from '../../containers/use_get_action_license'; import { getCasesColumns } from './columns'; import { AllCases } from '.'; @@ -27,12 +28,14 @@ jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/use_get_action_license'); const useKibanaMock = useKibana as jest.Mocked; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; +const useGetActionLicenseMock = useGetActionLicense as jest.Mock; jest.mock('../../../common/components/link_to'); @@ -86,6 +89,12 @@ describe('AllCases', () => { updateBulkStatus, }; + const defaultActionLicense = { + actionLicense: null, + isLoading: false, + isError: false, + }; + let navigateToApp: jest.Mock; beforeEach(() => { @@ -96,6 +105,7 @@ describe('AllCases', () => { useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); + useGetActionLicenseMock.mockReturnValue(defaultActionLicense); moment.tz.setDefault('UTC'); }); @@ -398,6 +408,7 @@ describe('AllCases', () => { expect(dispatchResetIsDeleted).toBeCalled(); }); }); + it('isUpdated is true, refetch', async () => { useUpdateCasesMock.mockReturnValue({ ...defaultUpdateCases, @@ -627,4 +638,56 @@ describe('AllCases', () => { ); }); }); + + it('should not allow the user to enter configuration page with basic license', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: false, + }, + }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + + it('should allow the user to enter configuration page with gold license and above', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index b1b5f2b087eeed..93890656b4a7f9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ConnectorTypes } from '../../../../../../case/common/api'; import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; -import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; -import { ConnectorTypes } from '../../../../../../case/common/api'; +import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; +import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; @@ -51,3 +52,9 @@ export const useConnectorsResponse: UseConnectorsResponse = { connectors, refetchConnectors: jest.fn(), }; + +export const useActionTypesResponse: UseActionTypesResponse = { + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: jest.fn(), +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx index 44767471dd9e78..9f3dcd168ba5f6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx @@ -38,6 +38,7 @@ const ConfigureCaseButtonComponent: React.FC = ({ }, [history, urlSearch] ); + const configureCaseButton = useMemo( () => ( = ({ ), [label, isDisabled, formatUrl, goToCaseConfigure] ); + return showToolTip ? ( ; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; +const useActionTypesMock = useActionTypes as jest.Mock; describe('ConfigureCases', () => { beforeEach(() => { @@ -83,6 +92,8 @@ describe('ConfigureCases', () => { /> )), } as unknown) as TriggersAndActionsUIPublicPluginStart; + + useActionTypesMock.mockImplementation(() => useActionTypesResponse); }); describe('rendering', () => { @@ -265,10 +276,12 @@ describe('ConfigureCases', () => { closureType: 'close-by-user', }, })); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, loading: true, })); + useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -294,6 +307,18 @@ describe('ConfigureCases', () => { .prop('disabled') ).toBe(true); }); + + test('it shows isLoading when loading action types', () => { + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: false, + })); + + useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true })); + + wrapper = mount(, { wrappingComponent: TestProviders }); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); }); describe('saving configuration', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index d34dc168ba7a2d..bc56e404c891de 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -9,16 +9,16 @@ import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; +import { SUPPORTED_CONNECTORS } from '../../../../../case/common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; +import { useActionTypes } from '../../containers/configure/use_action_types'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { ActionType } from '../../../../../triggers_actions_ui/public'; import { ClosureType } from '../../containers/configure/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types'; -import { connectorsConfiguration } from '../connectors'; import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; @@ -49,8 +49,6 @@ const FormWrapper = styled.div` `} `; -const actionTypes: ActionType[] = Object.values(connectorsConfiguration); - interface ConfigureCasesComponentProps { userCanCrud: boolean; } @@ -78,12 +76,20 @@ const ConfigureCasesComponent: React.FC = ({ userC } = useCaseConfigure(); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); + const { loading: isLoadingActionTypes, actionTypes, refetchActionTypes } = useActionTypes(); + const supportedActionTypes = useMemo( + () => actionTypes.filter((actionType) => SUPPORTED_CONNECTORS.includes(actionType.id)), + [actionTypes] + ); const onConnectorUpdate = useCallback(async () => { refetchConnectors(); + refetchActionTypes(); refetchCaseConfigure(); - }, [refetchCaseConfigure, refetchConnectors]); - const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; + }, [refetchActionTypes, refetchCaseConfigure, refetchConnectors]); + + const isLoadingAny = + isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -154,11 +160,11 @@ const ConfigureCasesComponent: React.FC = ({ userC triggersActionsUi.getAddConnectorFlyout({ consumer: 'case', onClose: onCloseAddFlyout, - actionTypes, + actionTypes: supportedActionTypes, reloadConnectors: onConnectorUpdate, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [supportedActionTypes] ); const ConnectorEditFlyout = useMemo( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx index 43f2a2a6e12f11..396ce0725eb3a8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx @@ -17,11 +17,16 @@ export const getLicenseError = () => ({ title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, description: ( + appropriateLicense: ( + + {i18n.LINK_APPROPRIATE_LICENSE} + + ), + cloud: ( + {i18n.LINK_CLOUD_DEPLOYMENT} ), diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts index f4539b8019d431..16f1b8965bb0b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts @@ -69,7 +69,7 @@ export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( 'xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle', { - defaultMessage: 'Upgrade to Elastic Platinum', + defaultMessage: 'Upgrade to an appropriate license', } ); @@ -80,6 +80,13 @@ export const LINK_CLOUD_DEPLOYMENT = i18n.translate( } ); +export const LINK_APPROPRIATE_LICENSE = i18n.translate( + 'xpack.securitySolution.case.caseView.appropiateLicense', + { + defaultMessage: 'appropriate license', + } +); + export const LINK_CONNECTOR_CONFIGURE = i18n.translate( 'xpack.securitySolution.case.caseView.connectorConfigureLink', { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts index 257cb171a4a9af..ed2f77657fb5ef 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts @@ -8,11 +8,12 @@ import { CasesConfigurePatch, CasesConfigureRequest, ActionConnector, + ActionTypeConnector, } from '../../../../../../case/common/api'; import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; -import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock'; +import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => Promise.resolve(connectorsMock); @@ -29,3 +30,6 @@ export const patchCaseConfigure = async ( caseConfiguration: CasesConfigurePatch, signal: AbortSignal ): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise => + Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts index f9115963c745dc..70576482fbe89e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts @@ -5,9 +5,16 @@ */ import { KibanaServices } from '../../../common/lib/kibana'; -import { fetchConnectors, getCaseConfigure, postCaseConfigure, patchCaseConfigure } from './api'; +import { + fetchConnectors, + getCaseConfigure, + postCaseConfigure, + patchCaseConfigure, + fetchActionTypes, +} from './api'; import { connectorsMock, + actionTypesMock, caseConfigurationMock, caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, @@ -123,4 +130,24 @@ describe('Case Configuration API', () => { expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); }); }); + + describe('fetch actionTypes', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionTypesMock); + }); + + test('check url, method, signal', async () => { + await fetchActionTypes({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/actions/list_action_types', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchActionTypes({ signal: abortCtrl.signal }); + expect(resp).toEqual(actionTypesMock); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 8652e48fd834d8..2b2bd1a782f75e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash/fp'; import { ActionConnector, + ActionTypeConnector, CasesConfigurePatch, CasesConfigureResponse, CasesConfigureRequest, @@ -16,6 +17,7 @@ import { KibanaServices } from '../../../common/lib/kibana'; import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL, + ACTION_TYPES_URL, } from '../../../../../case/common/constants'; import { ApiProps } from '../types'; @@ -89,3 +91,12 @@ export const patchCaseConfigure = async ( decodeCaseConfigureResponse(response) ); }; + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise => { + const response = await KibanaServices.get().http.fetch(ACTION_TYPES_URL, { + method: 'GET', + signal, + }); + + return response; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index fabd1187698a77..79aaaab61324ee 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -6,6 +6,7 @@ import { ActionConnector, + ActionTypeConnector, CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes, @@ -29,6 +30,7 @@ export const mappings: CaseConnectorMapping[] = [ actionType: 'append', }, ]; + export const connectorsMock: ActionConnector[] = [ { id: 'servicenow-1', @@ -60,6 +62,49 @@ export const connectorsMock: ActionConnector[] = [ }, ]; +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const caseConfigurationResposeMock: CasesConfigureResponse = { created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts index 41acb91f2ae96f..ff2441d361c2cc 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts @@ -7,6 +7,7 @@ import { ElasticUser } from '../types'; import { ActionConnector, + ActionTypeConnector, ActionType, CaseConnector, CaseField, @@ -15,7 +16,15 @@ import { ThirdPartyField, } from '../../../../../case/common/api'; -export { ActionConnector, ActionType, CaseConnector, CaseField, ClosureType, ThirdPartyField }; +export { + ActionConnector, + ActionTypeConnector, + ActionType, + CaseConnector, + CaseField, + ClosureType, + ThirdPartyField, +}; export interface CaseConnectorMapping { actionType: ActionType; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx new file mode 100644 index 00000000000000..b2213fb8fc8c41 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useActionTypes, UseActionTypesResponse } from './use_action_types'; +import { actionTypesMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useActionTypes', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('fetch action types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('refetch actionTypes', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + expect(spyOnfetchActionTypes).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching actionTypes', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + spyOnfetchActionTypes.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx new file mode 100644 index 00000000000000..980db8ed61f8fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -0,0 +1,72 @@ +/* + * 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 { useState, useEffect, useCallback, useRef } from 'react'; + +import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; +import * as i18n from '../translations'; +import { fetchActionTypes } from './api'; +import { ActionTypeConnector } from './types'; + +export interface UseActionTypesResponse { + loading: boolean; + actionTypes: ActionTypeConnector[]; + refetchActionTypes: () => void; +} + +export const useActionTypes = (): UseActionTypesResponse => { + const [, dispatchToaster] = useStateToaster(); + const [loading, setLoading] = useState(true); + const [actionTypes, setActionTypes] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + const queryFirstTime = useRef(true); + + const refetchActionTypes = useCallback(async () => { + try { + setLoading(true); + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + + const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + + if (!didCancel.current) { + setLoading(false); + setActionTypes(res); + } + } catch (error) { + if (!didCancel.current) { + setLoading(false); + setActionTypes([]); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + } + }, [dispatchToaster]); + + useEffect(() => { + if (queryFirstTime.current) { + refetchActionTypes(); + queryFirstTime.current = false; + } + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + queryFirstTime.current = true; + }; + }, [refetchActionTypes]); + + return { + loading, + actionTypes, + refetchActionTypes, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index fd24a8451fcbea..3fb962df232bc0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -199,6 +199,13 @@ export const actionLicenses: ActionLicense[] = [ enabledInConfig: true, enabledInLicense: true, }, + { + id: '.jira', + name: 'Jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, ]; // Snake case for mock api responses diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx index 23c9ff5e49586f..3e501a5276d5b3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx @@ -51,7 +51,7 @@ describe('useGetActionLicense', () => { expect(result.current).toEqual({ isLoading: false, isError: false, - actionLicense: actionLicenses[0], + actionLicense: actionLicenses[1], }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index e289a1973cf6ed..8ce5c4aeef4b6c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -23,6 +23,8 @@ export const initialData: ActionLicenseState = { isError: false, }; +const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; + export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); @@ -40,7 +42,8 @@ export const useGetActionLicense = (): ActionLicenseState => { const response = await getActionLicense(abortCtrl.signal); if (!didCancel) { setActionLicensesState({ - actionLicense: response.find((l) => l.id === '.servicenow') ?? null, + actionLicense: + response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, isLoading: false, isError: false, }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1c058245f04cdb..d6aeb3a293f675 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17338,7 +17338,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId](例:.servicenow | .jira)を追加します。詳細は{link}をご覧ください。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "外部システムでケースを開くには、ライセンスをプラチナに更新するか、30日間の無料トライアルを開始するか、AWS、GCP、またはAzureで{link}にサインアップする必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "E lastic Platinumへのアップグレード", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを選択", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e7dbc0c161a37e..c47be2f09ef82e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17382,7 +17382,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId](例如:.servicenow | .jira)添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "要在外部系统中打开案例,必须将许可证更新到白金级,开始为期 30 天的免费试用,或在 AWS、GCP 或 Azure 上快速部署 {link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "升级到 Elastic 白金级", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "选择外部连接器", From 9286b1352e25dae615b6516fc76fca4698da9bdd Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 29 Jan 2021 09:21:51 -0800 Subject: [PATCH 13/38] Rename PipelineProcessorsEditor to PipelineEditor to shorten import path to a length that Windows can handle, and to disambiguate with child component of the same name. (#89645) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../README.md | 0 .../__jest__/constants.ts | 0 .../__jest__/http_requests.helpers.ts | 0 .../__jest__/pipeline_processors_editor.helpers.tsx | 0 .../__jest__/pipeline_processors_editor.test.tsx | 0 .../__jest__/processors/processor.helpers.tsx | 0 .../__jest__/processors/uri_parts.test.tsx | 0 .../__jest__/processors_editor.tsx | 4 ++-- .../__jest__/test_pipeline.helpers.tsx | 0 .../__jest__/test_pipeline.test.tsx | 0 .../components/_shared.scss | 0 .../components/add_processor_button.tsx | 0 .../components/index.ts | 0 .../components/load_from_json/button.tsx | 0 .../components/load_from_json/index.ts | 0 .../components/load_from_json/modal_provider.test.tsx | 0 .../components/load_from_json/modal_provider.tsx | 0 .../components/on_failure_processors_title.tsx | 2 +- .../components/pipeline_processors_editor.tsx | 0 .../pipeline_processors_editor_item/context_menu.tsx | 0 .../pipeline_processors_editor_item/i18n_texts.ts | 0 .../pipeline_processors_editor_item/index.ts | 0 .../inline_text_input.tsx | 0 .../pipeline_processors_editor_item.container.tsx | 0 .../pipeline_processors_editor_item.scss | 0 .../pipeline_processors_editor_item.tsx | 0 .../pipeline_processors_editor_item/types.ts | 0 .../pipeline_processors_editor_item_status.tsx | 0 .../pipeline_processors_editor_item_tooltip/index.ts | 0 .../pipeline_processors_editor_item_toolip.scss | 0 .../pipeline_processors_editor_item_tooltip.tsx | 0 .../processor_information.tsx | 0 .../components/processor_form/add_processor_form.tsx | 0 .../processor_form/documentation_button.tsx | 0 .../components/processor_form/edit_processor_form.tsx | 0 .../field_components/drag_and_drop_text_list.scss | 0 .../field_components/drag_and_drop_text_list.tsx | 0 .../processor_form/field_components/index.ts | 0 .../processor_form/field_components/text_editor.scss | 0 .../processor_form/field_components/text_editor.tsx | 0 .../processor_form/field_components/xjson_editor.tsx | 0 .../components/processor_form/index.ts | 0 .../processor_form/processor_form.container.tsx | 0 .../processor_form/processor_output/index.ts | 0 .../processor_output/processor_output.scss | 0 .../processor_output/processor_output.tsx | 0 .../processor_form/processor_settings_fields.tsx | 0 .../components/processor_form/processors/append.tsx | 0 .../components/processor_form/processors/bytes.tsx | 0 .../components/processor_form/processors/circle.tsx | 0 .../common_fields/common_processor_fields.tsx | 0 .../processors/common_fields/field_name_field.tsx | 0 .../processors/common_fields/ignore_missing_field.tsx | 0 .../processor_form/processors/common_fields/index.ts | 0 .../processors/common_fields/processor_type_field.tsx | 0 .../processors/common_fields/properties_field.tsx | 0 .../processors/common_fields/target_field.tsx | 0 .../components/processor_form/processors/convert.tsx | 0 .../components/processor_form/processors/csv.tsx | 0 .../components/processor_form/processors/custom.tsx | 0 .../components/processor_form/processors/date.tsx | 0 .../processor_form/processors/date_index_name.tsx | 0 .../components/processor_form/processors/dissect.tsx | 0 .../processor_form/processors/dot_expander.tsx | 0 .../components/processor_form/processors/drop.tsx | 0 .../components/processor_form/processors/enrich.tsx | 0 .../components/processor_form/processors/fail.tsx | 0 .../components/processor_form/processors/foreach.tsx | 0 .../components/processor_form/processors/geoip.tsx | 0 .../processor_form/processors/grok.test.tsx | 0 .../components/processor_form/processors/grok.tsx | 0 .../components/processor_form/processors/gsub.tsx | 0 .../processor_form/processors/html_strip.tsx | 0 .../components/processor_form/processors/index.ts | 0 .../processor_form/processors/inference.tsx | 0 .../components/processor_form/processors/join.tsx | 0 .../components/processor_form/processors/json.tsx | 0 .../components/processor_form/processors/kv.tsx | 0 .../processor_form/processors/lowercase.tsx | 0 .../components/processor_form/processors/pipeline.tsx | 0 .../components/processor_form/processors/remove.tsx | 0 .../components/processor_form/processors/rename.tsx | 0 .../components/processor_form/processors/script.tsx | 0 .../components/processor_form/processors/set.tsx | 0 .../processor_form/processors/set_security_user.tsx | 0 .../components/processor_form/processors/shared.ts | 0 .../components/processor_form/processors/sort.tsx | 0 .../components/processor_form/processors/split.tsx | 0 .../components/processor_form/processors/trim.tsx | 0 .../processor_form/processors/uppercase.tsx | 0 .../processor_form/processors/uri_parts.tsx | 0 .../processor_form/processors/url_decode.tsx | 0 .../processor_form/processors/user_agent.tsx | 0 .../components/processor_remove_modal.tsx | 0 .../components/processors_empty_prompt.tsx | 0 .../components/processors_header.tsx | 0 .../processors_tree/components/drop_zone_button.tsx | 0 .../components/processors_tree/components/index.ts | 0 .../processors_tree/components/private_tree.tsx | 0 .../processors_tree/components/tree_node.tsx | 0 .../components/processors_tree/index.ts | 0 .../components/processors_tree/processors_tree.scss | 0 .../components/processors_tree/processors_tree.tsx | 0 .../components/processors_tree/utils.ts | 0 .../components/shared/index.ts | 0 .../components/shared/map_processor_type_to_form.tsx | 0 .../components/shared/status_icons/error_icon.tsx | 0 .../shared/status_icons/error_ignored_icon.tsx | 0 .../components/shared/status_icons/index.ts | 0 .../components/shared/status_icons/skipped_icon.tsx | 0 .../components/test_pipeline/add_documents_button.tsx | 0 .../documents_dropdown/documents_dropdown.scss | 0 .../documents_dropdown/documents_dropdown.tsx | 0 .../test_pipeline/documents_dropdown/index.ts | 0 .../components/test_pipeline/index.ts | 0 .../components/test_pipeline/test_output_button.tsx | 0 .../test_pipeline/test_pipeline_actions.tsx | 0 .../test_pipeline/test_pipeline_flyout.container.tsx | 0 .../components/test_pipeline/test_pipeline_flyout.tsx | 0 .../test_pipeline/test_pipeline_tabs/index.ts | 0 .../tab_documents/add_document_form.tsx | 0 .../add_documents_accordion.scss | 0 .../add_documents_accordion.tsx | 0 .../tab_documents/add_documents_accordion/index.ts | 0 .../test_pipeline_tabs/tab_documents/index.ts | 0 .../tab_documents/reset_documents_modal.tsx | 0 .../tab_documents/tab_documents.scss | 0 .../tab_documents/tab_documents.tsx | 0 .../test_pipeline/test_pipeline_tabs/tab_output.tsx | 0 .../test_pipeline_tabs/test_pipeline_tabs.tsx | 0 .../constants.ts | 0 .../context/context.tsx | 0 .../context/index.ts | 0 .../context/processors_context.tsx | 0 .../context/test_pipeline_context.tsx | 0 .../deserialize.test.ts | 0 .../deserialize.ts | 0 .../editors/global_on_failure_processors_editor.tsx | 0 .../editors/index.ts | 0 .../editors/processors_editor.tsx | 0 .../index.ts | 2 +- .../components/pipeline_editor/pipeline_editor.scss | 11 +++++++++++ .../pipeline_editor.tsx} | 8 ++++---- .../processors_reducer/constants.ts | 0 .../processors_reducer/index.ts | 0 .../processors_reducer/processors_reducer.test.ts | 0 .../processors_reducer/processors_reducer.ts | 0 .../processors_reducer/utils.ts | 0 .../serialize.ts | 0 .../types.ts | 0 .../use_is_mounted.ts | 0 .../utils.test.ts | 0 .../utils.ts | 0 .../components/pipeline_form/pipeline_form.tsx | 2 +- .../components/pipeline_form/pipeline_form_fields.tsx | 6 +++--- .../pipeline_processors_editor.scss | 11 ----------- 156 files changed, 23 insertions(+), 23 deletions(-) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/README.md (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/constants.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/http_requests.helpers.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/pipeline_processors_editor.helpers.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/pipeline_processors_editor.test.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/processors/processor.helpers.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/processors/uri_parts.test.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/processors_editor.tsx (89%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/test_pipeline.helpers.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/__jest__/test_pipeline.test.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/_shared.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/add_processor_button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/load_from_json/button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/load_from_json/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/load_from_json/modal_provider.test.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/load_from_json/modal_provider.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/on_failure_processors_title.tsx (96%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/context_menu.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/i18n_texts.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/inline_text_input.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/pipeline_processors_editor_item.container.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item/types.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item_status.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item_tooltip/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_toolip.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_tooltip.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/pipeline_processors_editor_item_tooltip/processor_information.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/add_processor_form.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/documentation_button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/edit_processor_form.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/drag_and_drop_text_list.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/drag_and_drop_text_list.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/text_editor.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/text_editor.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/field_components/xjson_editor.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processor_form.container.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processor_output/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processor_output/processor_output.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processor_output/processor_output.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processor_settings_fields.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/append.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/bytes.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/circle.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/common_processor_fields.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/field_name_field.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/ignore_missing_field.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/processor_type_field.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/properties_field.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/common_fields/target_field.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/convert.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/csv.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/custom.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/date.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/date_index_name.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/dissect.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/dot_expander.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/drop.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/enrich.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/fail.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/foreach.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/geoip.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/grok.test.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/grok.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/gsub.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/html_strip.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/inference.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/join.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/json.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/kv.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/lowercase.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/pipeline.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/remove.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/rename.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/script.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/set.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/set_security_user.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/shared.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/sort.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/split.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/trim.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/uppercase.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/uri_parts.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/url_decode.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_form/processors/user_agent.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processor_remove_modal.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_empty_prompt.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_header.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/components/drop_zone_button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/components/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/components/private_tree.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/components/tree_node.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/processors_tree.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/processors_tree.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/processors_tree/utils.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/map_processor_type_to_form.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/status_icons/error_icon.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/status_icons/error_ignored_icon.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/status_icons/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/shared/status_icons/skipped_icon.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/add_documents_button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/documents_dropdown/documents_dropdown.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/documents_dropdown/documents_dropdown.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/documents_dropdown/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_output_button.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_actions.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_flyout.container.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_flyout.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/add_document_form.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.scss (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/tab_output.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/components/test_pipeline/test_pipeline_tabs/test_pipeline_tabs.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/constants.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/context/context.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/context/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/context/processors_context.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/context/test_pipeline_context.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/deserialize.test.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/deserialize.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/editors/global_on_failure_processors_editor.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/editors/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/editors/processors_editor.tsx (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/index.ts (86%) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.scss rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor/pipeline_processors_editor.tsx => pipeline_editor/pipeline_editor.tsx} (85%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/processors_reducer/constants.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/processors_reducer/index.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/processors_reducer/processors_reducer.test.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/processors_reducer/processors_reducer.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/processors_reducer/utils.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/serialize.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/types.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/use_is_mounted.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/utils.test.ts (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor => pipeline_editor}/utils.ts (100%) delete mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx similarity index 89% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx index 8fb51ade921a9e..3fa245ff96d377 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx @@ -9,7 +9,7 @@ import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mock import { LocationDescriptorObject } from 'history'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; +import { ProcessorsEditorContextProvider, Props, PipelineEditor } from '../'; import { breadcrumbService, @@ -36,7 +36,7 @@ export const ProcessorsEditorWithDeps: React.FunctionComponent = (props) return ( - + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx similarity index 96% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx index 7adc37d1897d19..fe3e6d79f84d76 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx @@ -14,7 +14,7 @@ export const OnFailureProcessorsTitle: FunctionComponent = () => { const { services } = useKibana(); return ( -
    +

    = ({ onLoadJson }) => { +export const PipelineEditor: React.FunctionComponent = ({ onLoadJson }) => { const { state: { processors: allProcessors }, } = usePipelineProcessorsContext(); @@ -52,12 +52,12 @@ export const PipelineProcessorsEditor: React.FunctionComponent = ({ onLoa } return ( -
    +
    0} /> - + {content} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index ffd82b0bbaf355..ac8612a36dd7ea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -11,7 +11,7 @@ import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from import { useForm, Form, FormConfig } from '../../../shared_imports'; import { Pipeline, Processor } from '../../../../common/types'; -import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_processors_editor'; +import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_editor'; import { PipelineRequestFlyout } from './pipeline_request_flyout'; import { PipelineFormFields } from './pipeline_form_fields'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index a7ffe7ba02caab..b1b2e04e7d0dc9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -16,8 +16,8 @@ import { ProcessorsEditorContextProvider, OnUpdateHandler, OnDoneLoadJsonHandler, - PipelineProcessorsEditor, -} from '../pipeline_processors_editor'; + PipelineEditor, +} from '../pipeline_editor'; interface Props { processors: Processor[]; @@ -119,7 +119,7 @@ export const PipelineFormFields: React.FunctionComponent = ({ onUpdate={onProcessorsUpdate} value={{ processors, onFailure }} > - + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss deleted file mode 100644 index d5592b87dda51b..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss +++ /dev/null @@ -1,11 +0,0 @@ -.pipelineProcessorsEditor { - margin-bottom: $euiSizeXL; - - &__container { - background-color: $euiColorLightestShade; - } - - &__onFailureTitle { - padding-left: $euiSizeS; - } -} From ad8a2fb920b8b66045617f64f7a60453de275b2a Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 29 Jan 2021 10:28:48 -0700 Subject: [PATCH 14/38] [Maps] Implement searchSessionId in MapEmbeddable (#89342) * [Maps] Implement searchSessionId in MapEmbeddable * clean up * update method name * fix _unsubscribeFromStore subscription * fix unit test * add maps assertion to send_to_background_relative_time functional test * fix functional assertion Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data_request_descriptor_types.ts | 1 + .../maps/public/actions/map_actions.test.js | 20 ++++++++++ .../maps/public/actions/map_actions.ts | 5 +++ .../blended_vector_layer.ts | 5 ++- .../layers/vector_layer/vector_layer.tsx | 2 + .../es_geo_grid_source/es_geo_grid_source.tsx | 8 ++++ .../es_geo_line_source/es_geo_line_source.tsx | 2 + .../es_pew_pew_source/es_pew_pew_source.js | 1 + .../es_search_source/es_search_source.tsx | 2 + .../classes/sources/es_source/es_source.ts | 21 +++++++--- .../sources/es_term_source/es_term_source.ts | 1 + .../public/classes/util/can_skip_fetch.ts | 17 +++++++- .../maps/public/embeddable/map_embeddable.tsx | 40 ++++++++++--------- x-pack/plugins/maps/public/reducers/map.d.ts | 1 + x-pack/plugins/maps/public/reducers/map.js | 3 +- .../maps/public/selectors/map_selectors.ts | 16 +++++++- .../send_to_background_relative_time.ts | 11 +++++ 17 files changed, 128 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index b00281588734d4..c9391e1aac7490 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -18,6 +18,7 @@ export type MapFilters = { filters: Filter[]; query?: MapQuery; refreshTimerLastTriggeredAt?: string; + searchSessionId?: string; timeFilters: TimeRange; zoom: number; }; diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index 1d1f8a511c4fa9..c091aba14687a7 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -260,6 +260,7 @@ describe('map_actions', () => { $state: { store: 'appState' }, }, ]; + const searchSessionId = '1234'; beforeEach(() => { //Mocks the "previous" state @@ -272,6 +273,9 @@ describe('map_actions', () => { require('../selectors/map_selectors').getFilters = () => { return filters; }; + require('../selectors/map_selectors').getSearchSessionId = () => { + return searchSessionId; + }; require('../selectors/map_selectors').getMapSettings = () => { return { autoFitToDataBounds: false, @@ -288,12 +292,14 @@ describe('map_actions', () => { const setQueryAction = await setQuery({ query: newQuery, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); expect(dispatchMock.mock.calls).toEqual([ [ { + searchSessionId, timeFilters, query: newQuery, filters, @@ -304,11 +310,25 @@ describe('map_actions', () => { ]); }); + it('should dispatch query action when searchSessionId changes', async () => { + const setQueryAction = await setQuery({ + timeFilters, + query, + filters, + searchSessionId: '5678', + }); + await setQueryAction(dispatchMock, getStoreMock); + + // dispatchMock calls: dispatch(SET_QUERY) and dispatch(syncDataForAllLayers()) + expect(dispatchMock.mock.calls.length).toEqual(2); + }); + it('should not dispatch query action when nothing changes', async () => { const setQueryAction = await setQuery({ timeFilters, query, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 64c35bd2074398..afb3df5be73de8 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -19,6 +19,7 @@ import { getQuery, getTimeFilters, getLayerList, + getSearchSessionId, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -225,11 +226,13 @@ export function setQuery({ timeFilters, filters = [], forceRefresh = false, + searchSessionId, }: { filters?: Filter[]; query?: Query; timeFilters?: TimeRange; forceRefresh?: boolean; + searchSessionId?: string; }) { return async ( dispatch: ThunkDispatch, @@ -249,12 +252,14 @@ export function setQuery({ queryLastTriggeredAt: forceRefresh ? generateQueryTimestamp() : prevTriggeredAt, }, filters: filters ? filters : getFilters(getState()), + searchSessionId, }; const prevQueryContext = { timeFilters: getTimeFilters(getState()), query: getQuery(getState()), filters: getFilters(getState()), + searchSessionId: getSearchSessionId(getState()), }; if (_.isEqual(nextQueryContext, prevQueryContext)) { diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 5b33738a91a28f..88150da84f23f1 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -316,7 +316,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const abortController = new AbortController(); syncContext.registerCancelCallback(requestToken, () => abortController.abort()); const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); - const resp = await searchSource.fetch({ abortSignal: abortController.signal }); + const resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: syncContext.dataFilters.searchSessionId, + }); const maxResultWindow = await this._documentSource.getMaxResultWindow(); isSyncClustered = resp.hits.total > maxResultWindow; const countData = { isSyncClustered } as CountData; diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 0cb24be445c6ed..2304bb277da49d 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -616,6 +616,7 @@ export class VectorLayer extends AbstractLayer { sourceQuery, isTimeAware: this.getCurrentStyle().isTimeAware() && (await source.isTimeAware()), timeFilters: dataFilters.timeFilters, + searchSessionId: dataFilters.searchSessionId, } as VectorStyleRequestMeta; const prevDataRequest = this.getDataRequest(dataRequestId); const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); @@ -635,6 +636,7 @@ export class VectorLayer extends AbstractLayer { registerCancelCallback: registerCancelCallback.bind(null, requestToken), sourceQuery: nextMeta.sourceQuery, timeFilters: nextMeta.timeFilters, + searchSessionId: dataFilters.searchSessionId, }); stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 6ec51b8e118cb7..24b7e0dec519c4 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -195,6 +195,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle async _compositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -204,6 +205,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -280,6 +282,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle values: { requestId }, } ), + searchSessionId, }); features.push(...convertCompositeRespToGeoJson(esResponse, this._descriptor.requestType)); @@ -325,6 +328,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle // see https://github.com/elastic/kibana/pull/57875#issuecomment-590515482 for explanation on using separate code paths async _nonCompositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -332,6 +336,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -348,6 +353,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle requestDescription: i18n.translate('xpack.maps.source.esGrid.inspectorDescription', { defaultMessage: 'Elasticsearch geo grid aggregation request', }), + searchSessionId, }); return convertRegularRespToGeoJson(esResponse, this._descriptor.requestType); @@ -373,6 +379,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bucketsPerGrid === 1 ? await this._nonCompositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, @@ -381,6 +388,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle }) : await this._compositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx index 9c851dcedb3fac..916a8a291e6b4c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -213,6 +213,7 @@ export class ESGeoLineSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.esGeoLine.entityRequestDescription', { defaultMessage: 'Elasticsearch terms request to fetch entities within map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( entityResp, @@ -282,6 +283,7 @@ export class ESGeoLineSource extends AbstractESAggSource { defaultMessage: 'Elasticsearch geo_line request to fetch tracks for entities. Tracks are not filtered by map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection, numTrimmedTracks } = convertToGeoJson( tracksResp, diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 504212ea1ea84a..98d3ba6267c6db 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -148,6 +148,7 @@ export class ESPewPewSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.pewPew.inspectorDescription', { defaultMessage: 'Source-destination connections request', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection } = convertToLines(esResponse); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 5a923f0ce4292e..b70a433f2c7291 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -325,6 +325,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document top hits request', + searchSessionId: searchFilters.searchSessionId, }); const allHits: any[] = []; @@ -391,6 +392,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document request', + searchSessionId: searchFilters.searchSessionId, }); return { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 967131e900fc62..64a5cd575a19da 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -57,6 +57,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -64,6 +65,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise; } @@ -151,17 +153,19 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } async _runEsQuery({ + registerCancelCallback, + requestDescription, requestId, requestName, - requestDescription, + searchSessionId, searchSource, - registerCancelCallback, }: { + registerCancelCallback: (callback: () => void) => void; + requestDescription: string; requestId: string; requestName: string; - requestDescription: string; + searchSessionId?: string; searchSource: ISearchSource; - registerCancelCallback: (callback: () => void) => void; }): Promise { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); @@ -172,6 +176,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource inspectorRequest = inspectorAdapters.requests.start(requestName, { id: requestId, description: requestDescription, + searchSessionId, }); } @@ -186,7 +191,10 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } }); } - resp = await searchSource.fetch({ abortSignal: abortController.signal }); + resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + }); if (inspectorRequest) { const responseStats = search.getResponseInspectorStats(resp, searchSource); inspectorRequest.stats(responseStats).ok({ json: resp }); @@ -404,6 +412,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -411,6 +420,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise { const promises = dynamicStyleProps.map((dynamicStyleProp) => { return dynamicStyleProp.getFieldMetaRequest(); @@ -456,6 +466,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource 'Elasticsearch request retrieving field metadata used for calculating symbolization bands.', } ), + searchSessionId, }); return resp.aggregations; diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 12f1ef4829a4a8..235e8e3a651ee1 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -147,6 +147,7 @@ export class ESTermSource extends AbstractESAggSource { rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, }, }), + searchSessionId: searchFilters.searchSessionId, }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); diff --git a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts index a7919ad058e4b5..d7a5eea1516023 100644 --- a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts +++ b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts @@ -113,6 +113,7 @@ export async function canSkipSourceUpdate({ if (isQueryAware) { updateDueToApplyGlobalQuery = prevMeta.applyGlobalQuery !== nextMeta.applyGlobalQuery; updateDueToSourceQuery = !_.isEqual(prevMeta.sourceQuery, nextMeta.sourceQuery); + if (nextMeta.applyGlobalQuery) { updateDueToQuery = !_.isEqual(prevMeta.query, nextMeta.query); updateDueToFilters = !_.isEqual(prevMeta.filters, nextMeta.filters); @@ -123,6 +124,11 @@ export async function canSkipSourceUpdate({ } } + let updateDueToSearchSessionId = false; + if (timeAware || isQueryAware) { + updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + } + let updateDueToPrecisionChange = false; if (isGeoGridPrecisionAware) { updateDueToPrecisionChange = !_.isEqual(prevMeta.geogridPrecision, nextMeta.geogridPrecision); @@ -146,7 +152,8 @@ export async function canSkipSourceUpdate({ !updateDueToSourceQuery && !updateDueToApplyGlobalQuery && !updateDueToPrecisionChange && - !updateDueToSourceMetaChange + !updateDueToSourceMetaChange && + !updateDueToSearchSessionId ); } @@ -174,8 +181,14 @@ export function canSkipStyleMetaUpdate({ ? !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters) : false; + const updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + return ( - !updateDueToFields && !updateDueToSourceQuery && !updateDueToIsTimeAware && !updateDueToTime + !updateDueToFields && + !updateDueToSourceQuery && + !updateDueToIsTimeAware && + !updateDueToTime && + !updateDueToSearchSessionId ); } diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index bcdc23bddd2ebd..623e548aa85fa2 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -82,6 +82,7 @@ export class MapEmbeddable private _prevQuery?: Query; private _prevRefreshConfig?: RefreshInterval; private _prevFilters?: Filter[]; + private _prevSearchSessionId?: string; private _domNode?: HTMLElement; private _unsubscribeFromStore?: Unsubscribe; private _isInitialized = false; @@ -99,9 +100,7 @@ export class MapEmbeddable this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput }); this._initializeSaveMap(); - this._subscription = this.getUpdated$().subscribe(() => - this.onContainerStateChanged(this.input) - ); + this._subscription = this.getUpdated$().subscribe(() => this.onUpdate()); } private async _initializeSaveMap() { @@ -135,6 +134,7 @@ export class MapEmbeddable timeRange: this.input.timeRange, filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); if (this.input.refreshConfig) { this._dispatchSetRefreshConfig(this.input.refreshConfig); @@ -201,25 +201,24 @@ export class MapEmbeddable return getInspectorAdapters(this._savedMap.getStore().getState()); } - onContainerStateChanged(containerState: MapEmbeddableInput) { + onUpdate() { if ( - !_.isEqual(containerState.timeRange, this._prevTimeRange) || - !_.isEqual(containerState.query, this._prevQuery) || - !esFilters.onlyDisabledFiltersChanged(containerState.filters, this._prevFilters) + !_.isEqual(this.input.timeRange, this._prevTimeRange) || + !_.isEqual(this.input.query, this._prevQuery) || + !esFilters.onlyDisabledFiltersChanged(this.input.filters, this._prevFilters) || + this.input.searchSessionId !== this._prevSearchSessionId ) { this._dispatchSetQuery({ - query: containerState.query, - timeRange: containerState.timeRange, - filters: containerState.filters, + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); } - if ( - containerState.refreshConfig && - !_.isEqual(containerState.refreshConfig, this._prevRefreshConfig) - ) { - this._dispatchSetRefreshConfig(containerState.refreshConfig); + if (this.input.refreshConfig && !_.isEqual(this.input.refreshConfig, this._prevRefreshConfig)) { + this._dispatchSetRefreshConfig(this.input.refreshConfig); } } @@ -228,21 +227,25 @@ export class MapEmbeddable timeRange, filters = [], forceRefresh, + searchSessionId, }: { query?: Query; timeRange?: TimeRange; filters?: Filter[]; forceRefresh: boolean; + searchSessionId?: string; }) { this._prevTimeRange = timeRange; this._prevQuery = query; this._prevFilters = filters; + this._prevSearchSessionId = searchSessionId; this._savedMap.getStore().dispatch( setQuery({ filters: filters.filter((filter) => !filter.meta.disabled), query, timeFilters: timeRange, forceRefresh, + searchSessionId, }) ); } @@ -380,10 +383,11 @@ export class MapEmbeddable reload() { this._dispatchSetQuery({ - query: this._prevQuery, - timeRange: this._prevTimeRange, - filters: this._prevFilters ?? [], + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: true, + searchSessionId: this.input.searchSessionId, }); } diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 273d1de6fddfe5..52df65d6d2ecc3 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -34,6 +34,7 @@ export type MapContext = { refreshConfig?: MapRefreshConfig; refreshTimerLastTriggeredAt?: string; drawState?: DrawState; + searchSessionId?: string; }; export type MapSettings = { diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index 1395f2c5ce2fe8..f068abee48b93b 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -240,7 +240,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { }; return { ...state, mapState: { ...state.mapState, ...newMapState } }; case SET_QUERY: - const { query, timeFilters, filters } = action; + const { query, timeFilters, filters, searchSessionId } = action; return { ...state, mapState: { @@ -248,6 +248,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { query, timeFilters, filters, + searchSessionId, }, }; case SET_REFRESH_CONFIG: diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 8876b9536ce923..21ce5993b7c89e 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -169,6 +169,9 @@ export const getQuery = ({ map }: MapStoreState): MapQuery | undefined => map.ma export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.filters; +export const getSearchSessionId = ({ map }: MapStoreState): string | undefined => + map.mapState.searchSessionId; + export const isUsingSearch = (state: MapStoreState): boolean => { const filters = getFilters(state).filter((filter) => !filter.meta.disabled); const queryString = _.get(getQuery(state), 'query', ''); @@ -220,7 +223,17 @@ export const getDataFilters = createSelector( getRefreshTimerLastTriggeredAt, getQuery, getFilters, - (mapExtent, mapBuffer, mapZoom, timeFilters, refreshTimerLastTriggeredAt, query, filters) => { + getSearchSessionId, + ( + mapExtent, + mapBuffer, + mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + searchSessionId + ) => { return { extent: mapExtent, buffer: mapBuffer, @@ -229,6 +242,7 @@ export const getDataFilters = createSelector( refreshTimerLastTriggeredAt, query, filters, + searchSessionId, }; } ); diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts index ce6c8978c7d670..25291fd74b3225 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visChart', 'home', 'timePicker', + 'maps', ]); const dashboardPanelActions = getService('dashboardPanelActions'); const inspector = getService('inspector'); @@ -112,5 +113,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug('Checking vega chart rendered'); const tsvb = await find.existsByCssSelector('.vgaVis__view'); expect(tsvb).to.be(true); + log.debug('Checking map rendered'); + await dashboardPanelActions.openInspectorByTitle( + '[Flights] Origin and Destination Flight Time' + ); + await testSubjects.click('inspectorRequestChooser'); + await testSubjects.click(`inspectorRequestChooserFlight Origin Location`); + const requestStats = await inspector.getTableData(); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + expect(totalHits).to.equal('0'); + await inspector.close(); } } From 6a0f97fca738791cf7a5f2539814173c637ac39a Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 29 Jan 2021 09:35:20 -0800 Subject: [PATCH 15/38] [Enterprise Search] Minor Elastic Cloud setup guide instructions fixes (#89620) * Fix Cloud instructions copy when cloudDeploymentLink is missing * Fix missing i18n translations on copy nested within links Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../shared/setup_guide/cloud/instructions.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx index 26bbc8814d108a..9af5bfc0c3d403 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx @@ -34,10 +34,16 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl values={{ editDeploymentLink: cloudDeploymentLink ? ( - edit your deployment + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + )} ) : ( - 'Visit the Elastic Cloud console' + i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + ) ), }} /> @@ -76,7 +82,10 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl href={`${docLinks.enterpriseSearchBase}/configuration.html`} target="_blank" > - configurable options + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step3.instruction1LinkText', + { defaultMessage: 'configurable options' } + )} ), }} @@ -118,7 +127,10 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl href={`${docLinks.cloudBase}/ec-configure-index-management.html`} target="_blank" > - configure an index lifecycle policy + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step5.instruction1LinkText', + { defaultMessage: 'configure an index lifecycle policy' } + )} ), }} From 32058f9998addfff1b7e8dae4dfcf0cb3b33118a Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Fri, 29 Jan 2021 10:36:52 -0700 Subject: [PATCH 16/38] Remove geo threshold alert type (#89632) --- .../public/alert_types/geo_threshold/index.ts | 25 -- ...eshold_alert_type_expression.test.tsx.snap | 240 ----------- .../expressions/boundary_index_expression.tsx | 172 -------- .../expressions/entity_by_expression.tsx | 86 ---- .../expressions/entity_index_expression.tsx | 162 -------- ...o_threshold_alert_type_expression.test.tsx | 83 ---- .../geo_threshold/query_builder/index.tsx | 386 ------------------ .../expression_with_popover.tsx | 78 ---- .../geo_index_pattern_select.tsx | 150 ------- .../util_components/single_field_select.tsx | 84 ---- .../public/alert_types/geo_threshold/types.ts | 35 -- .../geo_threshold/validation.test.ts | 171 -------- .../alert_types/geo_threshold/validation.ts | 101 ----- .../stack_alerts/public/alert_types/index.ts | 2 - .../alert_types/geo_threshold/alert_type.ts | 240 ----------- .../geo_threshold/es_query_builder.ts | 202 --------- .../geo_threshold/geo_threshold.ts | 293 ------------- .../server/alert_types/geo_threshold/index.ts | 19 - .../__snapshots__/alert_type.test.ts.snap | 60 --- .../geo_threshold/tests/alert_type.test.ts | 66 --- .../tests/es_query_builder.test.ts | 67 --- .../tests/es_sample_response.json | 170 -------- .../es_sample_response_with_nesting.json | 170 -------- .../geo_threshold/tests/geo_threshold.test.ts | 268 ------------ .../stack_alerts/server/alert_types/index.ts | 2 - x-pack/plugins/stack_alerts/server/feature.ts | 7 +- .../stack_alerts/server/plugin.test.ts | 12 +- .../translations/translations/ja-JP.json | 52 --- .../translations/translations/zh-CN.json | 52 --- 29 files changed, 9 insertions(+), 3446 deletions(-) delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json delete mode 100644 x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts deleted file mode 100644 index 8ba632633a3af0..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { GeoThresholdAlertParams } from './types'; -import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; - -export function getAlertType(): AlertTypeModel { - return { - id: '.geo-threshold', - description: i18n.translate('xpack.stackAlerts.geoThreshold.descriptionText', { - defaultMessage: 'Alert when an entity enters or leaves a geo boundary.', - }), - iconClass: 'globe', - // TODO: Add documentation for geo threshold alert - documentationUrl: null, - alertParamsExpression: lazy(() => import('./query_builder')), - validate: validateExpression, - requiresAppContext: false, - }; -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap deleted file mode 100644 index ce59adc688c368..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap +++ /dev/null @@ -1,240 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render BoundaryIndexExpression 1`] = ` - - - - - - - - - - - - } -/> -`; - -exports[`should render EntityIndexExpression 1`] = ` - - - - - - } - labelType="label" - > - - - - - - - } -/> -`; - -exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` - - - - - - } - labelType="label" - > - - - - - - - } -/> -`; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx deleted file mode 100644 index 93918c82d664cb..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * 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, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_SHAPE_TYPES, GeoThresholdAlertParams } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - alertParams: GeoThresholdAlertParams; - errors: IErrorObject; - boundaryIndexPattern: IIndexPattern; - boundaryNameField?: string; - setBoundaryIndexPattern: (boundaryIndexPattern?: IIndexPattern) => void; - setBoundaryGeoField: (boundaryGeoField?: string) => void; - setBoundaryNameField: (boundaryNameField?: string) => void; - data: DataPublicPluginStart; -} - -interface KibanaDeps { - http: HttpSetup; -} - -export const BoundaryIndexExpression: FunctionComponent = ({ - alertParams, - errors, - boundaryIndexPattern, - boundaryNameField, - setBoundaryIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - data, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip']; - const { http } = useKibana().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - const { boundaryGeoField } = alertParams; - // eslint-disable-next-line react-hooks/exhaustive-deps - const nothingSelected: IFieldType = { - name: '', - type: 'string', - }; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(boundaryIndexPattern); - const fields = useRef<{ - geoFields: IFieldType[]; - boundaryNameFields: IFieldType[]; - }>({ - geoFields: [], - boundaryNameFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== boundaryIndexPattern) { - fields.current.geoFields = - (boundaryIndexPattern.fields.length && - boundaryIndexPattern.fields.filter((field: IFieldType) => - ES_GEO_SHAPE_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setBoundaryGeoField(fields.current.geoFields[0].name); - } - - fields.current.boundaryNameFields = [ - ...boundaryIndexPattern.fields.filter((field: IFieldType) => { - return ( - BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) && - !field.name.startsWith('_') && - !field.name.endsWith('keyword') - ); - }), - nothingSelected, - ]; - if (fields.current.boundaryNameFields.length) { - setBoundaryNameField(fields.current.boundaryNameFields[0].name); - } - } - }, [ - BOUNDARY_NAME_ENTITY_TYPES, - boundaryIndexPattern, - nothingSelected, - oldIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - ]); - - const indexPopover = ( - - - { - if (!_indexPattern) { - return; - } - setBoundaryIndexPattern(_indexPattern); - }} - value={boundaryIndexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http} - includedGeoTypes={ES_GEO_SHAPE_TYPES} - /> - - - - - - { - setBoundaryNameField(name === nothingSelected.name ? undefined : name); - }} - fields={fields.current.boundaryNameFields} - /> - - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx deleted file mode 100644 index 0cff207e674e53..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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, { FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import _ from 'lodash'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; - -interface Props { - errors: IErrorObject; - entity: string; - setAlertParamsEntity: (entity: string) => void; - indexFields: IFieldType[]; - isInvalid: boolean; -} - -export const EntityByExpression: FunctionComponent = ({ - errors, - entity, - setAlertParamsEntity, - indexFields, - isInvalid, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const ENTITY_TYPES = ['string', 'number', 'ip']; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexFields = usePrevious(indexFields); - const fields = useRef<{ - indexFields: IFieldType[]; - }>({ - indexFields: [], - }); - useEffect(() => { - if (!_.isEqual(oldIndexFields, indexFields)) { - fields.current.indexFields = indexFields.filter( - (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') - ); - if (!entity && fields.current.indexFields.length) { - setAlertParamsEntity(fields.current.indexFields[0].name); - } - } - }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); - - const indexPopover = ( - - _entity && setAlertParamsEntity(_entity)} - fields={fields.current.indexFields} - /> - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx deleted file mode 100644 index f2d2f7848a4f95..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { - IErrorObject, - AlertTypeParamsExpressionProps, -} from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_FIELD_TYPES } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - dateField: string; - geoField: string; - errors: IErrorObject; - setAlertParamsDate: (date: string) => void; - setAlertParamsGeoField: (geoField: string) => void; - setAlertProperty: AlertTypeParamsExpressionProps['setAlertProperty']; - setIndexPattern: (indexPattern: IIndexPattern) => void; - indexPattern: IIndexPattern; - isInvalid: boolean; - data: DataPublicPluginStart; -} - -export const EntityIndexExpression: FunctionComponent = ({ - setAlertParamsDate, - setAlertParamsGeoField, - errors, - setIndexPattern, - indexPattern, - isInvalid, - dateField: timeField, - geoField, - data, -}) => { - const { http } = useKibana().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(indexPattern); - const fields = useRef<{ - dateFields: IFieldType[]; - geoFields: IFieldType[]; - }>({ - dateFields: [], - geoFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== indexPattern) { - fields.current.geoFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => - ES_GEO_FIELD_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setAlertParamsGeoField(fields.current.geoFields[0].name); - } - - fields.current.dateFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => field.type === 'date')) || - []; - if (fields.current.dateFields.length) { - setAlertParamsDate(fields.current.dateFields[0].name); - } - } - }, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]); - - const indexPopover = ( - - - { - // reset time field and expression fields if indices are deleted - if (!_indexPattern) { - return; - } - setIndexPattern(_indexPattern); - }} - value={indexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http!} - includedGeoTypes={ES_GEO_FIELD_TYPES} - /> - - - } - > - - _timeField && setAlertParamsDate(_timeField) - } - fields={fields.current.dateFields} - /> - - - - _geoField && setAlertParamsGeoField(_geoField) - } - fields={fields.current.geoFields} - /> - - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx deleted file mode 100644 index c8158b0a6feaa1..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { shallow } from 'enzyme'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IErrorObject } from '../../../../../triggers_actions_ui/public'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { dataPluginMock } from 'src/plugins/data/public/mocks'; - -const alertParams = { - index: '', - indexId: '', - geoField: '', - entity: '', - dateField: '', - trackingEvent: '', - boundaryType: '', - boundaryIndexTitle: '', - boundaryIndexId: '', - boundaryGeoField: '', -}; - -const dataStartMock = dataPluginMock.createStartContract(); - -test('should render EntityIndexExpression', async () => { - const component = shallow( - {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={false} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render EntityIndexExpression w/ invalid flag if invalid', async () => { - const component = shallow( - {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={true} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render BoundaryIndexExpression', async () => { - const component = shallow( - {}} - setBoundaryGeoField={() => {}} - setBoundaryNameField={() => {}} - boundaryNameField={'testNameField'} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx deleted file mode 100644 index 2a08a4b32f076f..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx +++ /dev/null @@ -1,386 +0,0 @@ -/* - * 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, { Fragment, useEffect, useState } from 'react'; -import { - EuiCallOut, - EuiFieldNumber, - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIconTip, - EuiSelect, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - AlertTypeParamsExpressionProps, - getTimeOptions, -} from '../../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams, TrackingEvent } from '../types'; -import { ExpressionWithPopover } from './util_components/expression_with_popover'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { EntityByExpression } from './expressions/entity_by_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; -import { - esQuery, - esKuery, - Query, - QueryStringInput, -} from '../../../../../../../src/plugins/data/public'; - -const DEFAULT_VALUES = { - TRACKING_EVENT: '', - ENTITY: '', - INDEX: '', - INDEX_ID: '', - DATE_FIELD: '', - BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more - GEO_FIELD: '', - BOUNDARY_INDEX: '', - BOUNDARY_INDEX_ID: '', - BOUNDARY_GEO_FIELD: '', - BOUNDARY_NAME_FIELD: '', - DELAY_OFFSET_WITH_UNITS: '0m', -}; - -const conditionOptions = Object.keys(TrackingEvent).map((key) => ({ - text: TrackingEvent[key as TrackingEvent], - value: TrackingEvent[key as TrackingEvent], -})); - -const labelForDelayOffset = ( - <> - {' '} - - -); - -function validateQuery(query: Query) { - try { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - query.language === 'kuery' - ? esKuery.fromKueryExpression(query.query) - : esQuery.luceneStringToDsl(query.query); - } catch (err) { - return false; - } - return true; -} - -export const GeoThresholdAlertTypeExpression: React.FunctionComponent< - AlertTypeParamsExpressionProps -> = ({ alertParams, alertInterval, setAlertParams, setAlertProperty, errors, data }) => { - const { - index, - indexId, - indexQuery, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryIndexId, - boundaryIndexQuery, - boundaryGeoField, - boundaryNameField, - delayOffsetWithUnits, - } = alertParams; - - const [indexPattern, _setIndexPattern] = useState({ - id: '', - fields: [], - title: '', - }); - const setIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('index', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('indexId', _indexPattern.id); - } - } - }; - const [indexQueryInput, setIndexQueryInput] = useState( - indexQuery || { - query: '', - language: 'kuery', - } - ); - const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState({ - id: '', - fields: [], - title: '', - }); - const setBoundaryIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setBoundaryIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('boundaryIndexTitle', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('boundaryIndexId', _indexPattern.id); - } - } - }; - const [boundaryIndexQueryInput, setBoundaryIndexQueryInput] = useState( - boundaryIndexQuery || { - query: '', - language: 'kuery', - } - ); - const [delayOffset, _setDelayOffset] = useState(0); - function setDelayOffset(_delayOffset: number) { - setAlertParams('delayOffsetWithUnits', `${_delayOffset}${delayOffsetUnit}`); - _setDelayOffset(_delayOffset); - } - const [delayOffsetUnit, setDelayOffsetUnit] = useState('m'); - - const hasExpressionErrors = false; - const expressionErrorMessage = i18n.translate( - 'xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage', - { - defaultMessage: 'Expression contains errors.', - } - ); - - useEffect(() => { - const initToDefaultParams = async () => { - setAlertProperty('params', { - ...alertParams, - index: index ?? DEFAULT_VALUES.INDEX, - indexId: indexId ?? DEFAULT_VALUES.INDEX_ID, - entity: entity ?? DEFAULT_VALUES.ENTITY, - dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD, - trackingEvent: trackingEvent ?? DEFAULT_VALUES.TRACKING_EVENT, - boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE, - geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD, - boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX, - boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID, - boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD, - boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD, - delayOffsetWithUnits: delayOffsetWithUnits ?? DEFAULT_VALUES.DELAY_OFFSET_WITH_UNITS, - }); - if (!data?.indexPatterns) { - return; - } - if (indexId) { - const _indexPattern = await data?.indexPatterns.get(indexId); - setIndexPattern(_indexPattern); - } - if (boundaryIndexId) { - const _boundaryIndexPattern = await data?.indexPatterns.get(boundaryIndexId); - setBoundaryIndexPattern(_boundaryIndexPattern); - } - if (delayOffsetWithUnits) { - setDelayOffset(+delayOffsetWithUnits.replace(/\D/g, '')); - } - }; - initToDefaultParams(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {hasExpressionErrors ? ( - - - - - - ) : null} - - -
    - -
    -
    - - - - - - - { - setDelayOffset(+e.target.value); - }} - /> - - - { - setDelayOffsetUnit(e.target.value); - }} - /> - - - - - - - -
    - -
    -
    - - setAlertParams('dateField', _date)} - setAlertParamsGeoField={(_geoField) => setAlertParams('geoField', _geoField)} - setAlertProperty={setAlertProperty} - setIndexPattern={setIndexPattern} - indexPattern={indexPattern} - isInvalid={!indexId || !dateField || !geoField} - data={data} - /> - setAlertParams('entity', entityName)} - indexFields={indexPattern.fields} - isInvalid={indexId && dateField && geoField ? !entity : false} - /> - - - { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('indexQuery', query); - } - setIndexQueryInput(query); - } - }} - /> - - - - -
    - -
    -
    - - -
    - setAlertParams('trackingEvent', e.target.value)} - options={conditionOptions} - /> -
    - - } - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.whenEntityLabel', { - defaultMessage: 'when entity', - })} - /> - - - -
    - -
    -
    - - - _geoField && setAlertParams('boundaryGeoField', _geoField) - } - setBoundaryNameField={(_boundaryNameField: string | undefined) => - _boundaryNameField - ? setAlertParams('boundaryNameField', _boundaryNameField) - : setAlertParams('boundaryNameField', '') - } - boundaryNameField={boundaryNameField} - data={data} - /> - - - { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('boundaryIndexQuery', query); - } - setBoundaryIndexQueryInput(query); - } - }} - /> - - -
    - ); -}; - -// eslint-disable-next-line import/no-default-export -export { GeoThresholdAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx deleted file mode 100644 index a83667cfd92c64..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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, { ReactNode, useState } from 'react'; -import { - EuiButtonIcon, - EuiExpression, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export const ExpressionWithPopover: ({ - popoverContent, - expressionDescription, - defaultValue, - value, - isInvalid, -}: { - popoverContent: ReactNode; - expressionDescription: ReactNode; - defaultValue?: ReactNode; - value?: ReactNode; - isInvalid?: boolean; -}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => { - const [popoverOpen, setPopoverOpen] = useState(false); - - return ( - setPopoverOpen(true)} - isInvalid={isInvalid} - /> - } - isOpen={popoverOpen} - closePopover={() => setPopoverOpen(false)} - ownFocus - anchorPosition="downLeft" - zIndex={8000} - display="block" - > -
    - - - {expressionDescription} - - setPopoverOpen(false)} - /> - - - - {popoverContent} -
    -
    - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx deleted file mode 100644 index a552d6d998c7e3..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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, { Component } from 'react'; -import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPattern, IndexPatternsContract } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; - -interface Props { - onChange: (indexPattern: IndexPattern) => void; - value: string | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - IndexPatternSelectComponent: any; - indexPatternService: IndexPatternsContract | undefined; - http: HttpSetup; - includedGeoTypes: string[]; -} - -interface State { - noGeoIndexPatternsExist: boolean; -} - -export class GeoIndexPatternSelect extends Component { - private _isMounted: boolean = false; - - state = { - noGeoIndexPatternsExist: false, - }; - - componentWillUnmount() { - this._isMounted = false; - } - - componentDidMount() { - this._isMounted = true; - } - - _onIndexPatternSelect = async (indexPatternId: string) => { - if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) { - return; - } - - let indexPattern; - try { - indexPattern = await this.props.indexPatternService.get(indexPatternId); - } catch (err) { - return; - } - - // method may be called again before 'get' returns - // ignore response when fetched index pattern does not match active index pattern - if (this._isMounted && indexPattern.id === indexPatternId) { - this.props.onChange(indexPattern); - } - }; - - _onNoIndexPatterns = () => { - this.setState({ noGeoIndexPatternsExist: true }); - }; - - _renderNoIndexPatternWarning() { - if (!this.state.noGeoIndexPatternsExist) { - return null; - } - - return ( - <> - -

    - - - - - -

    -

    - - - - -

    -
    - - - ); - } - - render() { - const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent; - return ( - <> - {this._renderNoIndexPatternWarning()} - - - {IndexPatternSelectComponent ? ( - - ) : ( -
    - )} - - - ); - } -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx deleted file mode 100644 index ef6e6f6f5e18fe..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 _ from 'lodash'; -import React from 'react'; -import { - EuiComboBox, - EuiComboBoxOptionOption, - EuiHighlight, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { IFieldType } from 'src/plugins/data/public'; -import { FieldIcon } from '../../../../../../../../src/plugins/kibana_react/public'; - -function fieldsToOptions(fields?: IFieldType[]): Array> { - if (!fields) { - return []; - } - - return fields - .map((field) => ({ - value: field, - label: field.name, - })) - .sort((a, b) => { - return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); - }); -} - -interface Props { - placeholder: string; - value: string | null; // index pattern field name - onChange: (fieldName?: string) => void; - fields: IFieldType[]; -} - -export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) { - function renderOption( - option: EuiComboBoxOptionOption, - searchValue: string, - contentClassName: string - ) { - return ( - - - - - - {option.label} - - - ); - } - - const onSelection = (selectedOptions: Array>) => { - onChange(_.get(selectedOptions, '0.value.name')); - }; - - const selectedOptions: Array> = []; - if (value && fields) { - const selectedField = fields.find((field: IFieldType) => field.name === value); - if (selectedField) { - selectedOptions.push({ value: selectedField, label: value }); - } - } - - return ( - - ); -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts deleted file mode 100644 index 3f487135f0474d..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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'; -import { Query } from '../../../../../../src/plugins/data/common'; - -export enum TrackingEvent { - entered = 'entered', - exited = 'exited', - crossed = 'crossed', -} - -export interface GeoThresholdAlertParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} - -// Will eventually include 'geo_shape' -export const ES_GEO_FIELD_TYPES = ['geo_point']; -export const ES_GEO_SHAPE_TYPES = ['geo_shape']; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts deleted file mode 100644 index 9cc5b1eb069ae0..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * 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 { GeoThresholdAlertParams } from './types'; -import { validateExpression } from './validation'; - -describe('expression params validation', () => { - test('if index property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: '', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.index[0]).toBe('Index pattern is required.'); - }); - - test('if geoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: '', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.geoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.geoField[0]).toBe('Geo field is required.'); - }); - - test('if entity property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: '', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.entity.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.entity[0]).toBe('Entity is required.'); - }); - - test('if dateField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: '', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.dateField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.dateField[0]).toBe('Date field is required.'); - }); - - test('if trackingEvent property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.trackingEvent.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.trackingEvent[0]).toBe( - 'Tracking event is required.' - ); - }); - - test('if boundaryType property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: '', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryType.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryType[0]).toBe( - 'Boundary type is required.' - ); - }); - - test('if boundaryIndexTitle property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe( - 'Boundary index pattern title is required.' - ); - }); - - test('if boundaryGeoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryGeoField[0]).toBe( - 'Boundary geo field is required.' - ); - }); - - test('if boundaryNameField property is missing should not return error', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - boundaryNameField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts deleted file mode 100644 index 7a511f681ecaa1..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * 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 { ValidationResult } from '../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams } from './types'; - -export const validateExpression = (alertParams: GeoThresholdAlertParams): ValidationResult => { - const { - index, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryGeoField, - } = alertParams; - const validationResult = { errors: {} }; - const errors = { - index: new Array(), - indexId: new Array(), - geoField: new Array(), - entity: new Array(), - dateField: new Array(), - trackingEvent: new Array(), - boundaryType: new Array(), - boundaryIndexTitle: new Array(), - boundaryIndexId: new Array(), - boundaryGeoField: new Array(), - }; - validationResult.errors = errors; - - if (!index) { - errors.index.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText', { - defaultMessage: 'Index pattern is required.', - }) - ); - } - - if (!geoField) { - errors.geoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText', { - defaultMessage: 'Geo field is required.', - }) - ); - } - - if (!entity) { - errors.entity.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredEntityText', { - defaultMessage: 'Entity is required.', - }) - ); - } - - if (!dateField) { - errors.dateField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredDateFieldText', { - defaultMessage: 'Date field is required.', - }) - ); - } - - if (!trackingEvent) { - errors.trackingEvent.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText', { - defaultMessage: 'Tracking event is required.', - }) - ); - } - - if (!boundaryType) { - errors.boundaryType.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText', { - defaultMessage: 'Boundary type is required.', - }) - ); - } - - if (!boundaryIndexTitle) { - errors.boundaryIndexTitle.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText', { - defaultMessage: 'Boundary index pattern title is required.', - }) - ); - } - - if (!boundaryGeoField) { - errors.boundaryGeoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText', { - defaultMessage: 'Boundary geo field is required.', - }) - ); - } - - return validationResult; -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 654bf0a424f091..026383cd92f200 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -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'; @@ -19,7 +18,6 @@ export function registerAlertTypes({ config: Config; }) { if (config.enableGeoAlerting) { - alertTypeRegistry.register(getGeoThresholdAlertType()); alertTypeRegistry.register(getGeoContainmentAlertType()); } alertTypeRegistry.register(getThresholdAlertType()); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts deleted file mode 100644 index 27478049d48809..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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 } from '@kbn/config-schema'; -import { Logger } from 'src/core/server'; -import { STACK_ALERTS_FEATURE_ID } from '../../../common'; -import { getGeoThresholdExecutor } from './geo_threshold'; -import { - AlertType, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - AlertTypeParams, -} from '../../../../alerts/server'; -import { Query } from '../../../../../../src/plugins/data/common/query'; - -export const GEO_THRESHOLD_ID = '.geo-threshold'; -export type TrackingEvent = 'entered' | 'exited'; -export const ActionGroupId = 'tracking threshold met'; - -const actionVariableContextToEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel', - { - defaultMessage: `The time the entity was detected in the current boundary`, - } -); - -const actionVariableContextFromEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel', - { - defaultMessage: `The last time the entity was recorded in the previous boundary`, - } -); - -const actionVariableContextToEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel', - { - defaultMessage: 'The most recently captured location of the entity', - } -); - -const actionVariableContextCrossingLineLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel', - { - defaultMessage: - 'GeoJSON line connecting the two locations that were used to determine the crossing event', - } -); - -const actionVariableContextFromEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel', - { - defaultMessage: 'The previously captured location of the entity', - } -); - -const actionVariableContextToBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel', - { - defaultMessage: 'The current boundary id containing the entity (if any)', - } -); - -const actionVariableContextToBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located', - } -); - -const actionVariableContextFromBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located', - } -); - -const actionVariableContextFromBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel', - { - defaultMessage: 'The previous boundary id containing the entity (if any)', - } -); - -const actionVariableContextToEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextFromEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextTimeOfDetectionLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel', - { - defaultMessage: 'The alert interval end time this change was recorded', - } -); - -const actionVariableContextEntityIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel', - { - defaultMessage: 'The entity ID of the document that triggered the alert', - } -); - -const actionVariables = { - context: [ - // Alert-specific data - { name: 'entityId', description: actionVariableContextEntityIdLabel }, - { name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel }, - { name: 'crossingLine', description: actionVariableContextCrossingLineLabel }, - - // Corresponds to a specific document in the entity-index - { name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel }, - { - name: 'toEntityDateTime', - description: actionVariableContextToEntityDateTimeLabel, - }, - { name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index - { name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel }, - { name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel }, - - // Corresponds to a specific document in the entity-index (from) - { name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel }, - { name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel }, - { name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index (from) - { name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel }, - { name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel }, - ], -}; - -export const ParamsSchema = schema.object({ - index: schema.string({ minLength: 1 }), - indexId: schema.string({ minLength: 1 }), - geoField: schema.string({ minLength: 1 }), - entity: schema.string({ minLength: 1 }), - dateField: schema.string({ minLength: 1 }), - trackingEvent: schema.string({ minLength: 1 }), - boundaryType: schema.string({ minLength: 1 }), - boundaryIndexTitle: schema.string({ minLength: 1 }), - boundaryIndexId: schema.string({ minLength: 1 }), - boundaryGeoField: schema.string({ minLength: 1 }), - boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), - delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), - indexQuery: schema.maybe(schema.any({})), - boundaryIndexQuery: schema.maybe(schema.any({})), -}); - -export interface GeoThresholdParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} -export interface GeoThresholdState extends AlertTypeState { - shapesFilters: Record; - shapesIdsNamesMap: Record; - prevLocationArr: GeoThresholdInstanceState[]; -} -export interface GeoThresholdInstanceState extends AlertInstanceState { - location: number[]; - shapeLocationId: string; - entityName: string; - dateInShape: string | null; - docId: string; -} -export interface GeoThresholdInstanceContext extends AlertInstanceContext { - entityId: string; - timeOfDetection: number; - crossingLine: string; - toEntityLocation: string; - toEntityDateTime: string | null; - toEntityDocumentId: string; - toBoundaryId: string; - toBoundaryName: unknown; - fromEntityLocation: string; - fromEntityDateTime: string | null; - fromEntityDocumentId: string; - fromBoundaryId: string; - fromBoundaryName: unknown; -} - -export type GeoThresholdAlertType = AlertType< - GeoThresholdParams, - GeoThresholdState, - GeoThresholdInstanceState, - GeoThresholdInstanceContext, - typeof ActionGroupId ->; -export function getAlertType(logger: Logger): GeoThresholdAlertType { - const alertTypeName = i18n.translate('xpack.stackAlerts.geoThreshold.alertTypeTitle', { - defaultMessage: 'Tracking threshold', - }); - - const actionGroupName = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle', - { - defaultMessage: 'Tracking threshold met', - } - ); - - return { - id: GEO_THRESHOLD_ID, - name: alertTypeName, - actionGroups: [{ id: ActionGroupId, name: actionGroupName }], - defaultActionGroupId: ActionGroupId, - executor: getGeoThresholdExecutor(logger), - producer: STACK_ALERTS_FEATURE_ID, - validate: { - params: ParamsSchema, - }, - actionVariables, - minimumLicenseRequired: 'gold', - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts deleted file mode 100644 index 02ac19e7b6f1e9..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * 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 { ILegacyScopedClusterClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { - Query, - IIndexPattern, - fromKueryExpression, - toElasticsearchQuery, - luceneStringToDsl, -} from '../../../../../../src/plugins/data/common'; - -export const OTHER_CATEGORY = 'other'; -// Consider dynamically obtaining from config? -const MAX_TOP_LEVEL_QUERY_SIZE = 0; -const MAX_SHAPES_QUERY_SIZE = 10000; -const MAX_BUCKETS_LIMIT = 65535; - -export const getEsFormattedQuery = (query: Query, indexPattern?: IIndexPattern) => { - let esFormattedQuery; - - const queryLanguage = query.language; - if (queryLanguage === 'kuery') { - const ast = fromKueryExpression(query.query); - esFormattedQuery = toElasticsearchQuery(ast, indexPattern); - } else { - esFormattedQuery = luceneStringToDsl(query.query); - } - return esFormattedQuery; -}; - -export async function getShapesFilters( - boundaryIndexTitle: string, - boundaryGeoField: string, - geoField: string, - callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], - log: Logger, - alertId: string, - boundaryNameField?: string, - boundaryIndexQuery?: Query -) { - const filters: Record = {}; - const shapesIdsNamesMap: Record = {}; - // Get all shapes in index - const boundaryData: SearchResponse> = await callCluster('search', { - index: boundaryIndexTitle, - body: { - size: MAX_SHAPES_QUERY_SIZE, - ...(boundaryIndexQuery ? { query: getEsFormattedQuery(boundaryIndexQuery) } : {}), - }, - }); - - boundaryData.hits.hits.forEach(({ _index, _id }) => { - filters[_id] = { - geo_shape: { - [geoField]: { - indexed_shape: { - index: _index, - id: _id, - path: boundaryGeoField, - }, - }, - }, - }; - }); - if (boundaryNameField) { - boundaryData.hits.hits.forEach( - ({ _source, _id }: { _source: Record; _id: string }) => { - shapesIdsNamesMap[_id] = _source[boundaryNameField]; - } - ); - } - return { - shapesFilters: filters, - shapesIdsNamesMap, - }; -} - -export async function executeEsQueryFactory( - { - entity, - index, - dateField, - boundaryGeoField, - geoField, - boundaryIndexTitle, - indexQuery, - }: { - entity: string; - index: string; - dateField: string; - boundaryGeoField: string; - geoField: string; - boundaryIndexTitle: string; - boundaryNameField?: string; - indexQuery?: Query; - }, - { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, - log: Logger, - shapesFilters: Record -) { - return async ( - gteDateTime: Date | null, - ltDateTime: Date | null - ): Promise | undefined> => { - let esFormattedQuery; - if (indexQuery) { - const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null; - const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null; - const dateRangeUpdatedQuery = - indexQuery.language === 'kuery' - ? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})` - : `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`; - esFormattedQuery = getEsFormattedQuery({ - query: dateRangeUpdatedQuery, - language: indexQuery.language, - }); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const esQuery: Record = { - index, - body: { - size: MAX_TOP_LEVEL_QUERY_SIZE, - aggs: { - shapes: { - filters: { - other_bucket_key: OTHER_CATEGORY, - filters: shapesFilters, - }, - aggs: { - entitySplit: { - terms: { - size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2), - field: entity, - }, - aggs: { - entityHits: { - top_hits: { - size: 1, - sort: [ - { - [dateField]: { - order: 'desc', - }, - }, - ], - docvalue_fields: [entity, dateField, geoField], - _source: false, - }, - }, - }, - }, - }, - }, - }, - query: esFormattedQuery - ? esFormattedQuery - : { - bool: { - must: [], - filter: [ - { - match_all: {}, - }, - { - range: { - [dateField]: { - ...(gteDateTime ? { gte: gteDateTime } : {}), - lt: ltDateTime, // 'less than' to prevent overlap between intervals - format: 'strict_date_optional_time', - }, - }, - }, - ], - should: [], - must_not: [], - }, - }, - stored_fields: ['*'], - docvalue_fields: [ - { - field: dateField, - format: 'date_time', - }, - ], - }, - }; - - let esResult: SearchResponse | undefined; - try { - esResult = await callCluster('search', esQuery); - } catch (err) { - log.warn(`${err.message}`); - } - return esResult; - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts deleted file mode 100644 index a2375537ae6e5c..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * 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 _ from 'lodash'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; -import { - ActionGroupId, - GEO_THRESHOLD_ID, - GeoThresholdAlertType, - GeoThresholdInstanceState, -} from './alert_type'; - -export type LatestEntityLocation = GeoThresholdInstanceState; - -// Flatten agg results and get latest locations for each entity -export function transformResults( - results: SearchResponse | undefined, - dateField: string, - geoField: string -): LatestEntityLocation[] { - if (!results) { - return []; - } - - return ( - _.chain(results) - .get('aggregations.shapes.buckets', {}) - // @ts-expect-error - .flatMap((bucket: unknown, bucketKey: string) => { - const subBuckets = _.get(bucket, 'entitySplit.buckets', []); - return _.map(subBuckets, (subBucket) => { - const locationFieldResult = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${geoField}"][0]`, - '' - ); - const location = locationFieldResult - ? _.chain(locationFieldResult) - .split(', ') - .map((coordString) => +coordString) - .reverse() - .value() - : null; - const dateInShape = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${dateField}"][0]`, - null - ); - const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); - - return { - location, - shapeLocationId: bucketKey, - entityName: subBucket.key, - dateInShape, - docId, - }; - }); - }) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value() - ); -} - -interface EntityMovementDescriptor { - entityName: string; - currLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; - prevLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; -} - -export function getMovedEntities( - currLocationArr: LatestEntityLocation[], - prevLocationArr: LatestEntityLocation[], - trackingEvent: string -): EntityMovementDescriptor[] { - return ( - currLocationArr - // Check if shape has a previous location and has moved - .reduce( - ( - accu: EntityMovementDescriptor[], - { - entityName, - shapeLocationId, - dateInShape, - location, - docId, - }: { - entityName: string; - shapeLocationId: string; - dateInShape: string | null; - location: number[]; - docId: string; - } - ) => { - const prevLocationObj = prevLocationArr.find( - (locationObj: LatestEntityLocation) => locationObj.entityName === entityName - ); - if (!prevLocationObj) { - return accu; - } - if (shapeLocationId !== prevLocationObj.shapeLocationId) { - accu.push({ - entityName, - currLocation: { - location, - shapeId: shapeLocationId, - date: dateInShape, - docId, - }, - prevLocation: { - location: prevLocationObj.location, - shapeId: prevLocationObj.shapeLocationId, - date: prevLocationObj.dateInShape, - docId: prevLocationObj.docId, - }, - }); - } - return accu; - }, - [] - ) - // Do not track entries to or exits from 'other' - .filter((entityMovementDescriptor: EntityMovementDescriptor) => { - if (trackingEvent !== 'crossed') { - return trackingEvent === 'entered' - ? entityMovementDescriptor.currLocation.shapeId !== OTHER_CATEGORY - : entityMovementDescriptor.prevLocation.shapeId !== OTHER_CATEGORY; - } - return true; - }) - ); -} - -function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { - const timeUnit = delayOffsetWithUnits.slice(-1); - const time: number = +delayOffsetWithUnits.slice(0, -1); - - const adjustedDate = new Date(oldTime.getTime()); - if (timeUnit === 's') { - adjustedDate.setSeconds(adjustedDate.getSeconds() - time); - } else if (timeUnit === 'm') { - adjustedDate.setMinutes(adjustedDate.getMinutes() - time); - } else if (timeUnit === 'h') { - adjustedDate.setHours(adjustedDate.getHours() - time); - } else if (timeUnit === 'd') { - adjustedDate.setDate(adjustedDate.getDate() - time); - } - return adjustedDate; -} - -export const getGeoThresholdExecutor = (log: Logger): GeoThresholdAlertType['executor'] => - async function ({ previousStartedAt, startedAt, services, params, alertId, state }) { - const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters - ? state - : await getShapesFilters( - params.boundaryIndexTitle, - params.boundaryGeoField, - params.geoField, - services.callCluster, - log, - alertId, - params.boundaryNameField, - params.boundaryIndexQuery - ); - - const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); - - let currIntervalStartTime = previousStartedAt; - let currIntervalEndTime = startedAt; - if (params.delayOffsetWithUnits) { - if (currIntervalStartTime) { - currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); - } - currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); - } - - // Start collecting data only on the first cycle - if (!currIntervalStartTime) { - log.debug(`alert ${GEO_THRESHOLD_ID}:${alertId} alert initialized. Collecting data`); - // Consider making first time window configurable? - const tempPreviousEndTime = new Date(currIntervalEndTime); - tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - 5); - const prevToCurrentIntervalResults: - | SearchResponse - | undefined = await executeEsQuery(tempPreviousEndTime, currIntervalEndTime); - return { - prevLocationArr: transformResults( - prevToCurrentIntervalResults, - params.dateField, - params.geoField - ), - shapesFilters, - shapesIdsNamesMap, - }; - } - - const currentIntervalResults: SearchResponse | undefined = await executeEsQuery( - currIntervalStartTime, - currIntervalEndTime - ); - // No need to compare if no changes in current interval - if (!_.get(currentIntervalResults, 'hits.total.value')) { - return state; - } - - const currLocationArr: LatestEntityLocation[] = transformResults( - currentIntervalResults, - params.dateField, - params.geoField - ); - - const movedEntities: EntityMovementDescriptor[] = getMovedEntities( - currLocationArr, - state.prevLocationArr, - params.trackingEvent - ); - - // Create alert instances - movedEntities.forEach(({ entityName, currLocation, prevLocation }) => { - const toBoundaryName = shapesIdsNamesMap[currLocation.shapeId] || currLocation.shapeId; - const fromBoundaryName = shapesIdsNamesMap[prevLocation.shapeId] || prevLocation.shapeId; - let alertInstance; - if (params.trackingEvent === 'entered') { - alertInstance = `${entityName}-${toBoundaryName || currLocation.shapeId}`; - } else if (params.trackingEvent === 'exited') { - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}`; - } else { - // == 'crossed' - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}-${ - toBoundaryName || currLocation.shapeId - }`; - } - services.alertInstanceFactory(alertInstance).scheduleActions(ActionGroupId, { - entityId: entityName, - timeOfDetection: new Date(currIntervalEndTime).getTime(), - crossingLine: `LINESTRING (${prevLocation.location[0]} ${prevLocation.location[1]}, ${currLocation.location[0]} ${currLocation.location[1]})`, - - toEntityLocation: `POINT (${currLocation.location[0]} ${currLocation.location[1]})`, - toEntityDateTime: currLocation.date, - toEntityDocumentId: currLocation.docId, - - toBoundaryId: currLocation.shapeId, - toBoundaryName, - - fromEntityLocation: `POINT (${prevLocation.location[0]} ${prevLocation.location[1]})`, - fromEntityDateTime: prevLocation.date, - fromEntityDocumentId: prevLocation.docId, - - fromBoundaryId: prevLocation.shapeId, - fromBoundaryName, - }); - }); - - // Combine previous results w/ current results for state of next run - const prevLocationArr = _.chain(currLocationArr) - .concat(state.prevLocationArr) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value(); - - return { - prevLocationArr, - shapesFilters, - shapesIdsNamesMap, - }; - }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts deleted file mode 100644 index 2fa2bed9d8419d..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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)); -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap deleted file mode 100644 index 0cb04144fdb78d..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`alertType alert type creation structure is the expected value 1`] = ` -Object { - "context": Array [ - Object { - "description": "The entity ID of the document that triggered the alert", - "name": "entityId", - }, - Object { - "description": "The alert interval end time this change was recorded", - "name": "timeOfDetection", - }, - Object { - "description": "GeoJSON line connecting the two locations that were used to determine the crossing event", - "name": "crossingLine", - }, - Object { - "description": "The most recently captured location of the entity", - "name": "toEntityLocation", - }, - Object { - "description": "The time the entity was detected in the current boundary", - "name": "toEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "toEntityDocumentId", - }, - Object { - "description": "The current boundary id containing the entity (if any)", - "name": "toBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed into and is currently located", - "name": "toBoundaryName", - }, - Object { - "description": "The previously captured location of the entity", - "name": "fromEntityLocation", - }, - Object { - "description": "The last time the entity was recorded in the previous boundary", - "name": "fromEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "fromEntityDocumentId", - }, - Object { - "description": "The previous boundary id containing the entity (if any)", - "name": "fromBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed from and was previously located", - "name": "fromBoundaryName", - }, - ], -} -`; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts deleted file mode 100644 index 0cfce2d47f1898..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; -import { getAlertType, GeoThresholdParams } from '../alert_type'; - -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('.geo-threshold'); - expect(alertType.name).toBe('Tracking threshold'); - expect(alertType.actionGroups).toEqual([ - { id: 'tracking threshold met', name: 'Tracking threshold met' }, - ]); - - expect(alertType.actionVariables).toMatchSnapshot(); - }); - - it('validator succeeds with valid params', async () => { - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - delayOffsetWithUnits: 'testOffset', - }; - - expect(alertType.validate?.params?.validate(params)).toBeTruthy(); - }); - - it('validator fails with invalid params', async () => { - const paramsSchema = alertType.validate?.params; - if (!paramsSchema) throw new Error('params validator not set'); - - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - }; - - expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( - `"[trackingEvent]: value has length [0] but it must have a minimum length of [1]."` - ); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts deleted file mode 100644 index d577a88e8e2f8c..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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 { getEsFormattedQuery } from '../es_query_builder'; - -describe('esFormattedQuery', () => { - it('lucene queries are converted correctly', async () => { - const testLuceneQuery1 = { - query: `"airport": "Denver"`, - language: 'lucene', - }; - const esFormattedQuery1 = getEsFormattedQuery(testLuceneQuery1); - expect(esFormattedQuery1).toStrictEqual({ query_string: { query: '"airport": "Denver"' } }); - const testLuceneQuery2 = { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - language: 'lucene', - }; - const esFormattedQuery2 = getEsFormattedQuery(testLuceneQuery2); - expect(esFormattedQuery2).toStrictEqual({ - query_string: { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - }, - }); - }); - - it('kuery queries are converted correctly', async () => { - const testKueryQuery1 = { - query: `"airport": "Denver"`, - language: 'kuery', - }; - const esFormattedQuery1 = getEsFormattedQuery(testKueryQuery1); - expect(esFormattedQuery1).toStrictEqual({ - bool: { minimum_should_match: 1, should: [{ match_phrase: { airport: 'Denver' } }] }, - }); - const testKueryQuery2 = { - query: `"airport": "Denver" and ("animal": "goat" or "animal": "narwhal")`, - language: 'kuery', - }; - const esFormattedQuery2 = getEsFormattedQuery(testKueryQuery2); - expect(esFormattedQuery2).toStrictEqual({ - bool: { - filter: [ - { bool: { should: [{ match_phrase: { airport: 'Denver' } }], minimum_should_match: 1 } }, - { - bool: { - should: [ - { - bool: { should: [{ match_phrase: { animal: 'goat' } }], minimum_should_match: 1 }, - }, - { - bool: { - should: [{ match_phrase: { animal: 'narwhal' } }], - minimum_should_match: 1, - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json deleted file mode 100644 index 70edbd09aa5a13..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json deleted file mode 100644 index a4b7b6872b3415..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "geo.coords.location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "geo.coords.location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts deleted file mode 100644 index 5b5197ac62a393..00000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* - * 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 sampleJsonResponse from './es_sample_response.json'; -import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; -import { getMovedEntities, transformResults } from '../geo_threshold'; -import { OTHER_CATEGORY } from '../es_query_builder'; -import { SearchResponse } from 'elasticsearch'; - -describe('geo_threshold', () => { - describe('transformResults', () => { - const dateField = '@timestamp'; - const geoField = 'location'; - it('should correctly transform expected results', async () => { - const transformedResults = transformResults( - (sampleJsonResponse as unknown) as SearchResponse, - dateField, - geoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - const nestedDateField = 'time_data.@timestamp'; - const nestedGeoField = 'geo.coords.location'; - it('should correctly transform expected results if fields are nested', async () => { - const transformedResults = transformResults( - (sampleJsonResponseWithNesting as unknown) as SearchResponse, - nestedDateField, - nestedGeoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - it('should return an empty array if no results', async () => { - const transformedResults = transformResults(undefined, dateField, geoField); - expect(transformedResults).toEqual([]); - }); - }); - - describe('getMovedEntities', () => { - it('should return empty array if only movements were within same shapes', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-08-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 38.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should return result if entity has moved to different shape', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'currLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'currLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-27T18:01:41.190Z', - docId: 'prevLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 20.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - { - dateInShape: '2020-09-27T18:01:41.191Z', - docId: 'prevLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities.length).toEqual(1); - }); - - it('should ignore "entered" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should ignore "exited" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'exited'); - expect(movedEntities).toEqual([]); - }); - - it('should not ignore "crossed" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - - it('should not ignore "crossed" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 2a343cb49a91bb..5c35af5e344b9f 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -7,7 +7,6 @@ import { Logger } from 'src/core/server'; 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 { @@ -18,7 +17,6 @@ interface RegisterAlertTypesParams { export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); - registerGeoThreshold(params); registerGeoContainment(params); registerEsQuery(params); } diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 448e1e698858bd..e334b4642a00a4 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -6,7 +6,6 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; -import { GEO_THRESHOLD_ID as GeoThreshold } from './alert_types/geo_threshold/alert_type'; import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -21,7 +20,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold, GeoThreshold, GeoContainment], + alerting: [IndexThreshold, GeoContainment], privileges: { all: { app: [], @@ -30,7 +29,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoThreshold, GeoContainment], + all: [IndexThreshold, GeoContainment], read: [], }, savedObject: { @@ -48,7 +47,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold, GeoThreshold, GeoContainment], + read: [IndexThreshold, GeoContainment], }, savedObject: { all: [], diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 8d69fad4afa465..0273f373734fa0 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); - expect(alertingSetup.registerType).toHaveBeenCalledTimes(4); + expect(alertingSetup.registerType).toHaveBeenCalledTimes(3); const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0]; const testedIndexThresholdArgs = { @@ -58,16 +58,16 @@ describe('AlertingBuiltins Plugin', () => { Object { "actionGroups": Array [ Object { - "id": "tracking threshold met", - "name": "Tracking threshold met", + "id": "Tracked entity contained", + "name": "Tracking containment met", }, ], - "id": ".geo-threshold", - "name": "Tracking threshold", + "id": ".geo-containment", + "name": "Tracking containment", } `); - const esQueryArgs = alertingSetup.registerType.mock.calls[3][0]; + const esQueryArgs = alertingSetup.registerType.mock.calls[2][0]; const testedEsQueryArgs = { id: esQueryArgs.id, name: esQueryArgs.name, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d6aeb3a293f675..28ef79beb72cfd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20805,58 +20805,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "時間フィールド", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "追跡しきい値が満たされました", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "クロスイベントを決定するために使用された2つの場所を接続するGeoJSON行", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "エンティティを含む現在の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "アラートをトリガーしたドキュメントのエンティティ ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "エンティティを含む以前の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "エンティティがそこからクロスし、以前に検出された境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "前回エンティティが前の境界で記録された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "エンティティの以前に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "この変更が記録された、アラート間隔終了日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "エンティティがその中にクロスし、現在検出されている境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "現在の境界でエンティティが検出された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "エンティティの直近に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理追跡しきい値", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "境界名を選択", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)", - "xpack.stackAlerts.geoThreshold.delayOffset": "遅延評価オフセット", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "遅延サイクルでアラートを評価し、データレイテンシに合わせて調整します", - "xpack.stackAlerts.geoThreshold.descriptionText": "エンティティが地理的境界に出入りするときにアラートを発行します。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "グループ基準", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "境界インデックスパターンタイトルは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "境界タイプは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "日付フィールドが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "エンティティは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "インデックスパターンが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "追跡イベントは必須です。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空間フィールド", - "xpack.stackAlerts.geoThreshold.indexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "インデックスパターン", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "インデックスパターンを選択", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "インデックスパターンを作成します", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "次のことが必要です ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " 地理空間フィールドを含む", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "地理空間データセットがありませんか?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "地理空間フィールドを含むインデックスパターンが見つかりませんでした", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "境界を選択:", - "xpack.stackAlerts.geoThreshold.selectEntity": "エンティティを選択", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectIndex": "条件を定義してください", - "xpack.stackAlerts.geoThreshold.selectLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectOffset": "オフセットを選択(任意)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "時刻フィールドを選択", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "時間フィールド", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "エンティティ", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "しきい値一致", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "しきい値比較基準としきい値を説明する文字列", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "アラートがしきい値を超えた日付。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c47be2f09ef82e..052a00b1aefa48 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20853,58 +20853,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "时间字段", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "选择实体字段", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "已达到跟踪阈值", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "连接用于确定穿越事件的两个位置的 GeoJSON 线", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "包含实体的当前边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "触发了告警的文档的实体 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "包含实体的上一边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "实体从中穿越出且先前所位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "实体上次在上一边界中记录的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "实体的先前捕获位置", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "记录此更改的告警时间间隔结束时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "实体已穿越进且当前位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "在当前边界中检测到实体的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "实体的最近捕获位置", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理跟踪阈值", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "选择边界名称", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "可人工读取的边界名称(可选)", - "xpack.stackAlerts.geoThreshold.delayOffset": "已延迟的评估偏移", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "评估延迟周期内的告警,以针对数据延迟进行调整", - "xpack.stackAlerts.geoThreshold.descriptionText": "实体进入或离开地理边界时告警。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "依据", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "索引", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "“边界地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "“边界索引模式标题”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "“边界类型”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "“日期”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "“实体”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "“地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "“索引模式”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "“跟踪事件”必填。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空间字段", - "xpack.stackAlerts.geoThreshold.indexLabel": "索引", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "索引模式", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "选择索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "创建索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "您将需要 ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " (包含地理空间字段)。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "没有任何地理空间数据集?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "找不到任何具有地理空间字段的索引模式", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "选择边界:", - "xpack.stackAlerts.geoThreshold.selectEntity": "选择实体", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectIndex": "定义条件", - "xpack.stackAlerts.geoThreshold.selectLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectOffset": "选择偏移(可选)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "选择时间字段", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "时间字段", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "选择实体字段", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "当实体", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "已达到阈值", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "描述阈值比较运算符和阈值的字符串", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "告警超过阈值的日期。", From 5feca52dea33fafae81662b4a60582e94f63f278 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Fri, 29 Jan 2021 11:43:34 -0600 Subject: [PATCH 17/38] [Enterprise Search] Migrate Kibana plugin to TS project references (#87683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Enterprise Search] Migrate Kibana plugin to TS project references Part of #80508 * Add charts and un-comment added ‘features’ Also alphabetize. * Uncomment recently added security and spaces * Add last remaining reference * Add shared typings to cover svgs * Include package.json for version.ts * REvery adding package.json to include This did not fix the issue * Add correct references --- .../plugins/enterprise_search/tsconfig.json | 27 +++++++++++++++++++ x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 2 ++ x-pack/tsconfig.refs.json | 1 + 4 files changed, 31 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/tsconfig.json diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json new file mode 100644 index 00000000000000..6b4c50770b49f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 461ebfe15b1090..5232af0dd304b2 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "path": "../plugins/alerts/tsconfig.json"}, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/data_enhanced/tsconfig.json" }, + { "path": "../plugins/enterprise_search/tsconfig.json" }, { "path": "../plugins/global_search/tsconfig.json" }, { "path": "../plugins/global_search_providers/tsconfig.json" }, { "path": "../plugins/features/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index d64b17813f6606..4b161e3559849e 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -17,6 +17,7 @@ "plugins/features/**/*", "plugins/embeddable_enhanced/**/*", "plugins/event_log/**/*", + "plugins/enterprise_search/**/*", "plugins/licensing/**/*", "plugins/lens/**/*", "plugins/maps/**/*", @@ -85,6 +86,7 @@ { "path": "./plugins/discover_enhanced/tsconfig.json" }, { "path": "./plugins/global_search/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 694d359b6a05d0..f5b35c9429a1c5 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -15,6 +15,7 @@ { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/maps/tsconfig.json" }, { "path": "./plugins/maps_file_upload/tsconfig.json" }, { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, From 8780a2de6e8178d4084ce431afff58bd0edf19bc Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 29 Jan 2021 12:55:06 -0500 Subject: [PATCH 18/38] Better async (#89636) --- .../analytics/analytics_logic.test.ts | 33 ++++----- .../credentials/credentials_logic.test.ts | 57 +++++++-------- .../document_creation_logic.test.ts | 25 +++---- .../documents/document_detail_logic.test.ts | 26 +++---- .../components/engine/engine_logic.test.ts | 14 ++-- .../engine_overview_logic.test.ts | 19 ++--- .../components/engines/engines_logic.test.ts | 12 ++-- .../log_retention/log_retention_logic.test.ts | 29 +++----- .../indexing_status_logic.test.ts | 24 +++---- .../add_source/add_source_logic.test.ts | 69 ++++++++----------- .../display_settings_logic.test.ts | 33 ++++----- .../components/schema/schema_logic.test.ts | 59 +++++++--------- .../views/groups/group_logic.test.ts | 68 ++++++++---------- .../views/groups/groups_logic.test.ts | 69 +++++++++---------- .../views/settings/settings_logic.test.ts | 59 +++++++--------- 15 files changed, 247 insertions(+), 349 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts index 0901ff27378034..cb3273cc69387f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts @@ -9,13 +9,14 @@ import { mockKibanaValues, mockHttpValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'test-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants'; import { AnalyticsLogic } from './'; @@ -176,13 +177,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_ANALYTICS_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ANALYTICS_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries', @@ -220,25 +220,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); @@ -258,13 +256,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_QUERY_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_QUERY_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries/some-query', @@ -298,25 +295,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index cdd055fd367efe..2374bcb1b2d039 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; jest.mock('../../app_logic', () => ({ AppLogic: { @@ -17,9 +12,12 @@ jest.mock('../../app_logic', () => ({ values: { myRole: jest.fn(() => ({})) }, }, })); -import { AppLogic } from '../../app_logic'; +import { nextTick } from '@kbn/test/jest'; + +import { AppLogic } from '../../app_logic'; import { ApiTokenTypes } from './constants'; + import { CredentialsLogic } from './credentials_logic'; describe('CredentialsLogic', () => { @@ -1064,8 +1062,7 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `setCredentialsData` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'setCredentialsData').mockImplementationOnce(() => {}); - const promise = Promise.resolve({ meta, results }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ meta, results })); CredentialsLogic.actions.fetchCredentials(2); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials', { @@ -1073,17 +1070,16 @@ describe('CredentialsLogic', () => { 'page[current]': 2, }, }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsData).toHaveBeenCalledWith(meta, results); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchCredentials(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1095,12 +1091,11 @@ describe('CredentialsLogic', () => { jest .spyOn(CredentialsLogic.actions, 'setCredentialsDetails') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(credentialsDetails); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(credentialsDetails)); CredentialsLogic.actions.fetchDetails(); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials/details'); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsDetails).toHaveBeenCalledWith( credentialsDetails ); @@ -1108,11 +1103,10 @@ describe('CredentialsLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchDetails(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1124,23 +1118,21 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `onApiKeyDelete` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'onApiKeyDelete').mockImplementationOnce(() => {}); - const promise = Promise.resolve(); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve()); CredentialsLogic.actions.deleteApiKey(tokenName); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/credentials/${tokenName}`); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiKeyDelete).toHaveBeenCalledWith(tokenName); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.deleteApiKey(tokenName); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1156,14 +1148,13 @@ describe('CredentialsLogic', () => { activeApiToken: createdToken, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenCreateSuccess'); - const promise = Promise.resolve(createdToken); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(createdToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.post).toHaveBeenCalledWith('/api/app_search/credentials', { body: JSON.stringify(createdToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenCreateSuccess).toHaveBeenCalledWith(createdToken); expect(setSuccessMessage).toHaveBeenCalled(); }); @@ -1184,25 +1175,23 @@ describe('CredentialsLogic', () => { }, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenUpdateSuccess'); - const promise = Promise.resolve(updatedToken); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(updatedToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.put).toHaveBeenCalledWith('/api/app_search/credentials/test-key', { body: JSON.stringify(updatedToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenUpdateSuccess).toHaveBeenCalledWith(updatedToken); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.onApiTokenChange(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 2256d5ae7946a3..e1b562d9561eee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -6,6 +6,7 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; import dedent from 'dedent'; jest.mock('./utils', () => ({ @@ -443,10 +444,10 @@ describe('DocumentCreationLogic', () => { }); it('should set and show summary from the returned response', async () => { - const promise = http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); + http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); await DocumentCreationLogic.actions.uploadDocuments({ documents: mockValidDocuments }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith(mockValidResponse); expect(DocumentCreationLogic.actions.setCreationStep).toHaveBeenCalledWith( @@ -462,7 +463,7 @@ describe('DocumentCreationLogic', () => { }); it('handles API errors', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.reject({ body: { statusCode: 400, @@ -473,7 +474,7 @@ describe('DocumentCreationLogic', () => { ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( '[400 Bad Request] Invalid request payload JSON format' @@ -481,10 +482,10 @@ describe('DocumentCreationLogic', () => { }); it('handles client-side errors', async () => { - const promise = (http.post as jest.Mock).mockReturnValueOnce(new Error()); + (http.post as jest.Mock).mockReturnValueOnce(new Error()); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( "Cannot read property 'total' of undefined" @@ -493,14 +494,14 @@ describe('DocumentCreationLogic', () => { // NOTE: I can't seem to reproduce this in a production setting. it('handles errors returned from the API', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.resolve({ errors: ['JSON cannot be empty'], }) ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ 'JSON cannot be empty', @@ -536,12 +537,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge multiple API calls into a single summary obj', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce(mockFirstResponse) .mockReturnValueOnce(mockSecondResponse); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith({ @@ -562,12 +563,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge response errors', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce({ ...mockFirstResponse, errors: ['JSON cannot be empty'] }) .mockReturnValueOnce({ ...mockSecondResponse, errors: ['Too large to render'] }); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index e33cd9b0e9e71f..3a8861ee1e20e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -9,10 +9,11 @@ import { mockHttpValues, mockKibanaValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; import { mockEngineValues } from '../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { DocumentDetailLogic } from './document_detail_logic'; import { InternalSchemaTypes } from '../../../shared/types'; @@ -56,23 +57,21 @@ describe('DocumentDetailLogic', () => { it('will call an API endpoint and then store the result', async () => { const fields = [{ name: 'name', value: 'python', type: 'string' }]; jest.spyOn(DocumentDetailLogic.actions, 'setFields'); - const promise = Promise.resolve({ fields }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ fields })); DocumentDetailLogic.actions.getDocumentDetails('1'); expect(http.get).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(DocumentDetailLogic.actions.setFields).toHaveBeenCalledWith(fields); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); DocumentDetailLogic.actions.getDocumentDetails('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred', { isQueued: true }); expect(navigateToUrl).toHaveBeenCalledWith('/engines/engine1/documents'); @@ -81,13 +80,11 @@ describe('DocumentDetailLogic', () => { describe('deleteDocument', () => { let confirmSpy: any; - let promise: Promise; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); confirmSpy.mockImplementation(jest.fn(() => true)); - promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); }); afterEach(() => { @@ -99,7 +96,7 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Successfully marked document for deletion. It will be deleted momentarily.' ); @@ -113,16 +110,15 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).not.toHaveBeenCalled(); - await promise; + await nextTick(); }); it('handles errors', async () => { mount(); - promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); DocumentDetailLogic.actions.deleteDocument('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 48cbaeef70c1ae..616dae98e29f20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LogicMounter, mockHttpValues, expectedAsyncError } from '../../../__mocks__'; +import { LogicMounter, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { EngineLogic } from './'; @@ -172,11 +174,10 @@ describe('EngineLogic', () => { it('fetches and sets engine data', async () => { mount({ engineName: 'some-engine' }); jest.spyOn(EngineLogic.actions, 'setEngineData'); - const promise = Promise.resolve(mockEngineData); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineData)); EngineLogic.actions.initializeEngine(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine'); expect(EngineLogic.actions.setEngineData).toHaveBeenCalledWith(mockEngineData); @@ -185,11 +186,10 @@ describe('EngineLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(EngineLogic.actions, 'setEngineNotFound'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); EngineLogic.actions.initializeEngine(); - await expectedAsyncError(promise); + await nextTick(); expect(EngineLogic.actions.setEngineNotFound).toHaveBeenCalledWith(true); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts index b6620756699d51..9832387a563e32 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -4,17 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'some-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { EngineOverviewLogic } from './'; describe('EngineOverviewLogic', () => { @@ -85,11 +82,10 @@ describe('EngineOverviewLogic', () => { it('fetches data and calls onPollingSuccess', async () => { mount(); jest.spyOn(EngineOverviewLogic.actions, 'onPollingSuccess'); - const promise = Promise.resolve(mockEngineMetrics); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineMetrics)); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/overview'); expect(EngineOverviewLogic.actions.onPollingSuccess).toHaveBeenCalledWith( @@ -99,11 +95,10 @@ describe('EngineOverviewLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts index 5a83717aa00301..2e22c9b76cf6fb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts @@ -6,6 +6,8 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { EngineDetails } from '../engine/types'; import { EnginesLogic } from './'; @@ -124,13 +126,12 @@ describe('EnginesLogic', () => { describe('loadEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ enginesPage: 10 }); jest.spyOn(EnginesLogic.actions, 'onEnginesLoad'); EnginesLogic.actions.loadEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'indexed', pageIndex: 10 }, @@ -144,13 +145,12 @@ describe('EnginesLogic', () => { describe('loadMetaEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ metaEnginesPage: 99 }); jest.spyOn(EnginesLogic.actions, 'onMetaEnginesLoad'); EnginesLogic.actions.loadMetaEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'meta', pageIndex: 99 }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index bfdca6791edc13..18ab05a3676c65 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { LogRetentionOptions } from './types'; import { LogRetentionLogic } from './log_retention_logic'; @@ -202,8 +199,7 @@ describe('LogRetentionLogic', () => { it('will call an API endpoint and update log retention', async () => { jest.spyOn(LogRetentionLogic.actions, 'updateLogRetention'); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); @@ -215,7 +211,7 @@ describe('LogRetentionLogic', () => { }), }); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -224,11 +220,10 @@ describe('LogRetentionLogic', () => { }); it('handles errors', async () => { - const promise = Promise.reject('An error occured'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); @@ -276,14 +271,13 @@ describe('LogRetentionLogic', () => { .spyOn(LogRetentionLogic.actions, 'updateLogRetention') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.fetchLogRetention(); expect(LogRetentionLogic.values.isLogRetentionUpdating).toBe(true); expect(http.get).toHaveBeenCalledWith('/api/app_search/log_settings'); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -293,11 +287,10 @@ describe('LogRetentionLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(LogRetentionLogic.actions, 'clearLogRetentionUpdating'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.fetchLogRetention(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts index 0a80f8e3610253..cfff8cc5578368 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { IndexingStatusLogic } from './indexing_status_logic'; @@ -57,37 +54,34 @@ describe('IndexingStatusLogic', () => { it('calls API and sets values', async () => { const setIndexingStatusSpy = jest.spyOn(IndexingStatusLogic.actions, 'setIndexingStatus'); - const promise = Promise.resolve(mockStatusResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(mockStatusResponse)); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); expect(http.get).toHaveBeenCalledWith(statusPath); - await promise; + await nextTick(); expect(setIndexingStatusSpy).toHaveBeenCalledWith(mockStatusResponse); }); it('handles error', async () => { - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); it('handles indexing complete state', async () => { - const promise = Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 })); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await promise; + await nextTick(); expect(clearInterval).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(mockStatusResponse.numDocumentsWithErrors); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index d08f807691c2be..058645bd308624 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; import { AppLogic } from '../../../../app_logic'; jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { CustomSource } from '../../../../types'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; @@ -271,23 +268,21 @@ describe('AddSourceLogic', () => { describe('getSourceConfigData', () => { it('calls API and sets values', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve(sourceConfigData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); AddSourceLogic.actions.getSourceConfigData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/settings/connectors/github' ); - await promise; + await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConfigData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -302,15 +297,14 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceConnectData('github', successCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/sources/github/prepare'); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); expect(successCallback).toHaveBeenCalledWith(sourceConnectData.oauthUrl); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -327,11 +321,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConnectData('github', successCallback); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -343,24 +336,22 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceReConnectData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/github/reauth_prepare' ); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceReConnectData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -372,22 +363,20 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setPreContentSourceConfigData' ); - const promise = Promise.resolve(config); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(config)); AddSourceLogic.actions.getPreContentSourceConfigData('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/pre_sources/123'); - await promise; + await nextTick(); expect(setPreContentSourceConfigDataSpy).toHaveBeenCalledWith(config); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getPreContentSourceConfigData('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -414,8 +403,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve({ sourceConfigData }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.saveSourceConfig(true, successCallback); @@ -428,7 +416,7 @@ describe('AddSourceLogic', () => { { body: JSON.stringify({ params }) } ); - await promise; + await nextTick(); expect(successCallback).toHaveBeenCalled(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -453,11 +441,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.saveSourceConfig(true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -495,8 +482,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - const promise = Promise.resolve({ sourceConfigData }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -505,18 +491,17 @@ describe('AddSourceLogic', () => { expect(http.post).toHaveBeenCalledWith('/api/workplace_search/org/create_source', { body: JSON.stringify({ ...params }), }); - await promise; + await nextTick(); expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); - await expectedAsyncError(promise); + await nextTick(); expect(errorCallback).toHaveBeenCalled(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts index aed99bdd950c54..d43afd589468f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts @@ -6,11 +6,7 @@ import { LogicMounter } from '../../../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -22,6 +18,8 @@ jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { exampleResult } from '../../../../__mocks__/content_sources.mock'; import { LEAVE_UNASSIGNED_FIELD } from './constants'; @@ -286,14 +284,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: true, @@ -307,14 +304,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: false, @@ -322,10 +318,9 @@ describe('DisplaySettingsLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.initializeDisplaySettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -337,25 +332,23 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'setServerResponseData' ); - const promise = Promise.resolve(serverProps); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.onInitializeDisplaySettings(serverProps); DisplaySettingsLogic.actions.setServerData(); expect(http.post).toHaveBeenCalledWith(serverProps.serverRoute, { body: JSON.stringify({ ...searchResultConfig }), }); - await promise; + await nextTick(); expect(setServerResponseDataSpy).toHaveBeenCalledWith({ ...serverProps, }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.setServerData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 2c3aa6114c7da9..c9d68201f33eed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -198,14 +195,13 @@ describe('SchemaLogic', () => { describe('initializeSchema', () => { it('calls API and sets values (org)', async () => { const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); @@ -213,22 +209,20 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.initializeSchema(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,13 +291,12 @@ describe('SchemaLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject({ error: 'this is an error' }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ error: 'this is an error' })); SchemaLogic.actions.initializeSchemaFieldErrors( mostRecentIndexJob.activeReindexJobId, contentSource.id ); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith({ error: 'this is an error', @@ -352,8 +345,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -362,7 +354,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_FIELD_ADDED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -371,8 +363,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -381,16 +372,15 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { const onSchemaSetFormErrorsSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetFormErrors'); - const promise = Promise.reject({ message: 'this is an error' }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject({ message: 'this is an error' })); SchemaLogic.actions.setServerField(schema, ADD); - await expectedAsyncError(promise); + await nextTick(); expect(onSchemaSetFormErrorsSpy).toHaveBeenCalledWith('this is an error'); }); @@ -400,8 +390,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -410,7 +399,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_UPDATED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -419,8 +408,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -429,15 +417,14 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.setServerField(schema, UPDATE); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts index e90acd929a9909..2e7a028e43aec0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts @@ -9,9 +9,10 @@ import { mockKibanaValues, mockFlashMessageHelpers, mockHttpValues, - expectedAsyncError, } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { groups } from '../../__mocks__/groups.mock'; import { mockGroupValues } from './__mocks__/group_logic.mock'; import { GroupLogic } from './group_logic'; @@ -229,32 +230,29 @@ describe('GroupLogic', () => { describe('initializeGroup', () => { it('calls API and sets values', async () => { const onInitializeGroupSpy = jest.spyOn(GroupLogic.actions, 'onInitializeGroup'); - const promise = Promise.resolve(group); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.initializeGroup(sourceIds[0]); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(onInitializeGroupSpy).toHaveBeenCalledWith(group); }); it('handles 404 error', async () => { - const promise = Promise.reject({ response: { status: 404 } }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ response: { status: 404 } })); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('Unable to find group with ID: "123".'); }); it('handles non-404 error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('this is an error'); @@ -266,13 +264,12 @@ describe('GroupLogic', () => { GroupLogic.actions.onInitializeGroup(group); }); it('deletes a group', async () => { - const promise = Promise.resolve(true); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve(true)); GroupLogic.actions.deleteGroup(); expect(http.delete).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Group "group" was successfully deleted.' @@ -280,11 +277,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.deleteGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,15 +293,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupNameChangedSpy = jest.spyOn(GroupLogic.actions, 'onGroupNameChanged'); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.updateGroupName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123', { body: JSON.stringify({ group: { name: 'new name' } }), }); - await promise; + await nextTick(); expect(onGroupNameChangedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully renamed this group to "group".' @@ -313,11 +308,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.updateGroupName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -330,15 +324,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupSourcesSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupSourcesSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSources(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/share', { body: JSON.stringify({ content_source_ids: sourceIds }), }); - await promise; + await nextTick(); expect(onGroupSourcesSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared content sources.' @@ -346,11 +339,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSources(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -362,15 +354,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupUsersSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupUsersSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupUsers(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/assign', { body: JSON.stringify({ user_ids: userIds }), }); - await promise; + await nextTick(); expect(onGroupUsersSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated the users of this group.' @@ -378,11 +369,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupUsers(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -397,8 +387,7 @@ describe('GroupLogic', () => { GroupLogic.actions, 'onGroupPrioritiesChanged' ); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSourcePrioritization(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123/boosts', { @@ -410,7 +399,7 @@ describe('GroupLogic', () => { }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared source prioritization.' ); @@ -418,11 +407,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSourcePrioritization(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts index 76352a66706500..6c9f912a98ce8a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { DEFAULT_META } from '../../../shared/constants'; import { JSON_HEADER as headers } from '../../../../../common/constants'; @@ -22,7 +19,6 @@ import { GroupsLogic } from './groups_logic'; // We need to mock out the debounced functionality const TIMEOUT = 400; -const delay = () => new Promise((resolve) => setTimeout(resolve, TIMEOUT)); describe('GroupsLogic', () => { const { mount } = new LogicMounter(GroupsLogic); @@ -218,21 +214,19 @@ describe('GroupsLogic', () => { describe('initializeGroups', () => { it('calls API and sets values', async () => { const onInitializeGroupsSpy = jest.spyOn(GroupsLogic.actions, 'onInitializeGroups'); - const promise = Promise.resolve(groupsResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(groupsResponse)); GroupsLogic.actions.initializeGroups(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups'); - await promise; + await nextTick(); expect(onInitializeGroupsSpy).toHaveBeenCalledWith(groupsResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.initializeGroups(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -256,15 +250,22 @@ describe('GroupsLogic', () => { headers, }; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('calls API and sets values', async () => { const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); @@ -272,24 +273,22 @@ describe('GroupsLogic', () => { // Set active page to 2 to confirm resetting sends the `payload` value of 1 for the current page. GroupsLogic.actions.setActivePage(2); const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(true); // Account for `breakpoint` that debounces filter value. - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.getSearchResults(); - await expectedAsyncError(promise); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -298,21 +297,19 @@ describe('GroupsLogic', () => { describe('fetchGroupUsers', () => { it('calls API and sets values', async () => { const setGroupUsersSpy = jest.spyOn(GroupsLogic.actions, 'setGroupUsers'); - const promise = Promise.resolve(users); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(users)); GroupsLogic.actions.fetchGroupUsers('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123/group_users'); - await promise; + await nextTick(); expect(setGroupUsersSpy).toHaveBeenCalledWith(users); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.fetchGroupUsers('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -323,24 +320,22 @@ describe('GroupsLogic', () => { const GROUP_NAME = 'new group'; GroupsLogic.actions.setNewGroupName(GROUP_NAME); const setNewGroupSpy = jest.spyOn(GroupsLogic.actions, 'setNewGroup'); - const promise = Promise.resolve(groups[0]); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups[0])); GroupsLogic.actions.saveNewGroup(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups', { body: JSON.stringify({ group_name: GROUP_NAME }), headers, }); - await promise; + await nextTick(); expect(setNewGroupSpy).toHaveBeenCalledWith(groups[0]); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.saveNewGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index aaeae08d552d49..e21b62b5000675 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -6,12 +6,9 @@ import { LogicMounter } from '../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, - mockKibanaValues, -} from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { configuredSources, oauthApplication } from '../../__mocks__/content_sources.mock'; @@ -89,20 +86,18 @@ describe('SettingsLogic', () => { describe('initializeSettings', () => { it('calls API and sets values', async () => { const setServerPropsSpy = jest.spyOn(SettingsLogic.actions, 'setServerProps'); - const promise = Promise.resolve(configuredSources); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(configuredSources)); SettingsLogic.actions.initializeSettings(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings'); - await promise; + await nextTick(); expect(setServerPropsSpy).toHaveBeenCalledWith(configuredSources); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeSettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -114,20 +109,18 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'onInitializeConnectors' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); SettingsLogic.actions.initializeConnectors(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors'); - await promise; + await nextTick(); expect(onInitializeConnectorsSpy).toHaveBeenCalledWith(serverProps); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeConnectors(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -138,25 +131,23 @@ describe('SettingsLogic', () => { const NAME = 'updated name'; SettingsLogic.actions.onOrgNameInputChange(NAME); const setUpdatedNameSpy = jest.spyOn(SettingsLogic.actions, 'setUpdatedName'); - const promise = Promise.resolve({ organizationName: NAME }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ organizationName: NAME })); SettingsLogic.actions.updateOrgName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/customize', { body: JSON.stringify({ name: NAME }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOrgName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); @@ -168,8 +159,7 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'setUpdatedOauthApplication' ); - const promise = Promise.resolve({ oauthApplication }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ oauthApplication })); SettingsLogic.actions.setOauthApplication(oauthApplication); SettingsLogic.actions.updateOauthApplication(); @@ -183,16 +173,15 @@ describe('SettingsLogic', () => { }), } ); - await promise; + await nextTick(); expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOauthApplication(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -203,20 +192,18 @@ describe('SettingsLogic', () => { const NAME = 'baz'; it('calls API and sets values', async () => { - const promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); expect(setQueuedSuccessMessage).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); From d6227fbb307c2cb1d3185250f99597b29fbc80d5 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Fri, 29 Jan 2021 13:18:06 -0500 Subject: [PATCH 19/38] [Upgrade Assistant] Clean up i18n (#89661) --- .../checkup/deprecations/index_table.test.tsx | 6 +- .../tabs/checkup/deprecations/index_table.tsx | 29 ++++---- .../overview/deprecation_logging_toggle.tsx | 42 +++++------ .../components/tabs/overview/steps.tsx | 70 ++++++++++--------- 4 files changed, 77 insertions(+), 70 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx index 1c9a079bcf1eba..772d558a0d20dd 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { shallow } from 'enzyme'; -import { IndexDeprecationTableProps, IndexDeprecationTableUI } from './index_table'; +import { IndexDeprecationTableProps, IndexDeprecationTable } from './index_table'; describe('IndexDeprecationTable', () => { const defaultProps = { @@ -22,7 +22,7 @@ describe('IndexDeprecationTable', () => { // This test simply verifies that the props passed to EuiBaseTable are the ones // expected. test('render', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot(` + expect(shallow()).toMatchInlineSnapshot(` { @@ -49,24 +49,27 @@ export class IndexDeprecationTableUI extends React.Component< } public render() { - const { intl } = this.props; const { pageIndex, pageSize, sortField, sortDirection } = this.state; const columns = [ { field: 'index', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', - defaultMessage: 'Index', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', + { + defaultMessage: 'Index', + } + ), sortable: true, }, { field: 'details', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', - defaultMessage: 'Details', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', + { + defaultMessage: 'Details', + } + ), }, ]; @@ -169,5 +172,3 @@ export class IndexDeprecationTableUI extends React.Component< }; } } - -export const IndexDeprecationTable = injectI18n(IndexDeprecationTableUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx index 0e6c79dc47b537..7a1ffb955db5c2 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { HttpSetup } from 'src/core/public'; import { LoadingState } from '../../types'; -interface DeprecationLoggingTabProps extends ReactIntl.InjectedIntlProps { +interface DeprecationLoggingTabProps { http: HttpSetup; } @@ -22,7 +22,7 @@ interface DeprecationLoggingTabState { loggingEnabled?: boolean; } -export class DeprecationLoggingToggleUI extends React.Component< +export class DeprecationLoggingToggle extends React.Component< DeprecationLoggingTabProps, DeprecationLoggingTabState > { @@ -59,27 +59,29 @@ export class DeprecationLoggingToggleUI extends React.Component< } private renderLoggingState() { - const { intl } = this.props; const { loggingEnabled, loadingState } = this.state; if (loadingState === LoadingState.Error) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', - defaultMessage: 'Could not load logging state', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', + { + defaultMessage: 'Could not load logging state', + } + ); } else if (loggingEnabled) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', - defaultMessage: 'On', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', + { + defaultMessage: 'On', + } + ); } else { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', - defaultMessage: 'Off', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', + { + defaultMessage: 'Off', + } + ); } } @@ -117,5 +119,3 @@ export class DeprecationLoggingToggleUI extends React.Component< } }; } - -export const DeprecationLoggingToggle = injectI18n(DeprecationLoggingToggleUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx index 1a1ea48a350c8c..dd392f6d1b2946 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx @@ -17,7 +17,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { CURRENT_MAJOR_VERSION, NEXT_MAJOR_VERSION } from '../../../../../common/version'; import { UpgradeAssistantTabProps } from '../../types'; @@ -89,10 +89,9 @@ const START_UPGRADE_STEP = (isCloudEnabled: boolean, esDocBasePath: string) => ( ), }); -export const StepsUI: FunctionComponent = ({ +export const Steps: FunctionComponent = ({ checkupData, setSelectedTabIndex, - intl, }) => { const checkupDataTyped = (checkupData! as unknown) as { [checkupType: string]: any[] }; const countByType = Object.keys(checkupDataTyped).reduce((counts, checkupType) => { @@ -113,15 +112,18 @@ export const StepsUI: FunctionComponent @@ -168,15 +170,18 @@ export const StepsUI: FunctionComponent @@ -222,10 +227,12 @@ export const StepsUI: FunctionComponent @@ -256,11 +263,12 @@ export const StepsUI: FunctionComponent @@ -276,5 +284,3 @@ export const StepsUI: FunctionComponent ); }; - -export const Steps = injectI18n(StepsUI); From 7609fb9351f7e9c7289e96eea19421d68fb9112c Mon Sep 17 00:00:00 2001 From: Pier-Hugues Pellerin Date: Fri, 29 Jan 2021 13:21:53 -0500 Subject: [PATCH 20/38] Update code owners for Fleet (#89715) Rename ingest-management to fleet. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dea2c12756b089..3343544d57fad2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -99,7 +99,7 @@ # Observability UIs /x-pack/plugins/infra/ @elastic/logs-metrics-ui -/x-pack/plugins/fleet/ @elastic/ingest-management +/x-pack/plugins/fleet/ @elastic/fleet /x-pack/plugins/observability/ @elastic/observability-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime From a6fe0a2de78a8766ff0f34272e6d81daa11a2568 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Fri, 29 Jan 2021 10:36:50 -0800 Subject: [PATCH 21/38] Fix error thrown when Kibana is sent a SIGHUP to reload logging config (#89218) * Fix error thrown when Kibana is sent a SIGHUP to reload logging config * Adding a simple unit test to catch a future regression Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/setup_logging.test.ts | 35 +++++++++++++++++++ .../kbn-legacy-logging/src/setup_logging.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-legacy-logging/src/setup_logging.test.ts diff --git a/packages/kbn-legacy-logging/src/setup_logging.test.ts b/packages/kbn-legacy-logging/src/setup_logging.test.ts new file mode 100644 index 00000000000000..6386b400329b96 --- /dev/null +++ b/packages/kbn-legacy-logging/src/setup_logging.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Server } from '@hapi/hapi'; +import { reconfigureLogging, setupLogging } from './setup_logging'; +import { LegacyLoggingConfig } from './schema'; + +describe('reconfigureLogging', () => { + test(`doesn't throw an error`, () => { + const server = new Server(); + const config: LegacyLoggingConfig = { + silent: false, + quiet: false, + verbose: true, + events: {}, + dest: '/tmp/foo', + filter: {}, + json: true, + rotate: { + enabled: false, + everyBytes: 0, + keepFiles: 0, + pollingInterval: 0, + usePolling: false, + }, + }; + setupLogging(server, config, 10); + reconfigureLogging(server, { ...config, dest: '/tmp/bar' }, 0); + }); +}); diff --git a/packages/kbn-legacy-logging/src/setup_logging.ts b/packages/kbn-legacy-logging/src/setup_logging.ts index 4370e4ab77d68b..ffe3be558f366f 100644 --- a/packages/kbn-legacy-logging/src/setup_logging.ts +++ b/packages/kbn-legacy-logging/src/setup_logging.ts @@ -37,5 +37,5 @@ export function reconfigureLogging( opsInterval: number ) { const loggingOptions = getLoggingConfiguration(config, opsInterval); - (server.plugins as any)['@elastic/good'].reconfigure(loggingOptions); + (server.plugins as any).good.reconfigure(loggingOptions); } From 2055cb96bae7850deba68220edb3ce4545f0e8b3 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Fri, 29 Jan 2021 13:38:18 -0500 Subject: [PATCH 22/38] Adds find by value embeddables helper (#89629) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/dashboard/server/index.ts | 1 + .../usage/find_by_value_embeddables.test.ts | 60 +++++++++++++++++++ .../server/usage/find_by_value_embeddables.ts | 34 +++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts create mode 100644 src/plugins/dashboard/server/usage/find_by_value_embeddables.ts diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index cc784f5f81c9e8..4bd43d1cd64a90 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -25,3 +25,4 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { DashboardPluginSetup, DashboardPluginStart } from './types'; +export { findByValueEmbeddables } from './usage/find_by_value_embeddables'; diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts new file mode 100644 index 00000000000000..3da6a8050f14c4 --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { SavedDashboardPanel730ToLatest } from '../../common'; +import { findByValueEmbeddables } from './find_by_value_embeddables'; + +const visualizationByValue = ({ + embeddableConfig: { + value: 'visualization-by-value', + }, + type: 'visualization', +} as unknown) as SavedDashboardPanel730ToLatest; + +const mapByValue = ({ + embeddableConfig: { + value: 'map-by-value', + }, + type: 'map', +} as unknown) as SavedDashboardPanel730ToLatest; + +const embeddableByRef = ({ + panelRefName: 'panel_ref_1', +} as unknown) as SavedDashboardPanel730ToLatest; + +describe('findByValueEmbeddables', () => { + it('finds the by value embeddables for the given type', async () => { + const savedObjectsResult = { + saved_objects: [ + { + attributes: { + panelsJSON: JSON.stringify([visualizationByValue, mapByValue, embeddableByRef]), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify([embeddableByRef, mapByValue, visualizationByValue]), + }, + }, + ], + }; + const savedObjectClient = { find: jest.fn().mockResolvedValue(savedObjectsResult) }; + + const maps = await findByValueEmbeddables(savedObjectClient, 'map'); + + expect(maps.length).toBe(2); + expect(maps[0]).toEqual(mapByValue.embeddableConfig); + expect(maps[1]).toEqual(mapByValue.embeddableConfig); + + const visualizations = await findByValueEmbeddables(savedObjectClient, 'visualization'); + + expect(visualizations.length).toBe(2); + expect(visualizations[0]).toEqual(visualizationByValue.embeddableConfig); + expect(visualizations[1]).toEqual(visualizationByValue.embeddableConfig); + }); +}); diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts new file mode 100644 index 00000000000000..0ae14cdcf71975 --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server'; +import { SavedDashboardPanel730ToLatest } from '../../common'; + +export const findByValueEmbeddables = async ( + savedObjectClient: Pick, + embeddableType: string +) => { + const dashboards = await savedObjectClient.find({ + type: 'dashboard', + }); + + return dashboards.saved_objects + .map((dashboard) => { + try { + return (JSON.parse( + dashboard.attributes.panelsJSON as string + ) as unknown) as SavedDashboardPanel730ToLatest[]; + } catch (exception) { + return []; + } + }) + .flat() + .filter((panel) => (panel as Record).panelRefName === undefined) + .filter((panel) => panel.type === embeddableType) + .map((panel) => panel.embeddableConfig); +}; From c5ad2ca5dd9de87d82e2b2908f7c82a78ea2563d Mon Sep 17 00:00:00 2001 From: Pier-Hugues Pellerin Date: Fri, 29 Jan 2021 13:39:28 -0500 Subject: [PATCH 23/38] Adjust Path labeller for Team:Fleet (#89769) Move from Team:Ingest management to Team:Fleet --- .github/paths-labeller.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index f74870578ecb1a..81d57be9b2d951 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -10,7 +10,7 @@ - "src/plugins/bfetch/**/*.*" - "Team:apm": - "x-pack/plugins/apm/**/*.*" - - "Team:Ingest Management": + - "Team:Fleet": - "x-pack/plugins/fleet/**/*.*" - "x-pack/test/fleet_api_integration/**/*.*" - "Team:uptime": From 4f6de5a407d2f06edad2599883aac8668eb69272 Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 29 Jan 2021 11:42:37 -0800 Subject: [PATCH 24/38] [App Search] Add final Analytics table components (#89233) * Add new AnalyticsSection component * Update views that use AnalyticsSection * [Setup] Update types + final API logic data - export query types so that new table components can use them - reorganize type keys by their (upcoming) table column order, remove unused tags from document obj * [Setup] Migrate InlineTagsList component - used for tags columns in all tables * Create basic AnalyticsTable component - there's a lot of logic separated out into constants.tsx right now, I promise it will make more sense when the one-off tables get added * Update all views that use AnalyticsTable + add 'view all' button links to overview tables * Add RecentQueriesTable component - Why is the API for this specific table so different? who knows, but it do be that way * Update views with RecentQueryTable * Add QueryClicksTable component to QueryDetails view * Create AnalyticsSearch bar for queries subpages * [Polish] Add some space to the bottom of analytics pages * [Design feedback] Tweak header + search form layout - Have analytics filter form be on its own row separate from page title - Change AnalyticsSearch to stretch to full width + add placeholder text + match header gutter + remain one line on mobile * [PR feedback] Type clarification * [PR feedback] Clear mocks * [PR suggestion] File rename constants.tsx -> shared_columns.tsx Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/analytics/analytics_layout.tsx | 2 + .../analytics/analytics_logic.test.ts | 24 ++--- .../components/analytics/analytics_logic.ts | 36 +++++++ .../components/analytics_header.scss | 14 +++ .../analytics/components/analytics_header.tsx | 12 ++- .../components/analytics_search.test.tsx | 56 +++++++++++ .../analytics/components/analytics_search.tsx | 53 ++++++++++ .../components/analytics_section.test.tsx | 24 +++++ .../components/analytics_section.tsx | 28 ++++++ .../analytics_tables/analytics_table.test.tsx | 90 +++++++++++++++++ .../analytics_tables/analytics_table.tsx | 76 ++++++++++++++ .../components/analytics_tables/index.ts | 9 ++ .../inline_tags_list.test.tsx | 38 +++++++ .../analytics_tables/inline_tags_list.tsx | 44 +++++++++ .../query_clicks_table.test.tsx | 77 +++++++++++++++ .../analytics_tables/query_clicks_table.tsx | 78 +++++++++++++++ .../recent_queries_table.test.tsx | 85 ++++++++++++++++ .../analytics_tables/recent_queries_table.tsx | 82 +++++++++++++++ .../analytics_tables/shared_columns.tsx | 99 +++++++++++++++++++ .../components/analytics/components/index.ts | 3 + .../app_search/components/analytics/types.ts | 16 ++- .../analytics/views/analytics.test.tsx | 28 +++++- .../components/analytics/views/analytics.tsx | 96 +++++++++++++++++- .../analytics/views/query_detail.test.tsx | 3 +- .../analytics/views/query_detail.tsx | 18 +++- .../analytics/views/recent_queries.test.tsx | 6 +- .../analytics/views/recent_queries.tsx | 8 +- .../analytics/views/top_queries.test.tsx | 6 +- .../analytics/views/top_queries.tsx | 8 +- .../views/top_queries_no_clicks.test.tsx | 6 +- .../analytics/views/top_queries_no_clicks.tsx | 8 +- .../views/top_queries_no_results.test.tsx | 6 +- .../views/top_queries_no_results.tsx | 8 +- .../views/top_queries_with_clicks.test.tsx | 6 +- .../views/top_queries_with_clicks.tsx | 8 +- 35 files changed, 1114 insertions(+), 47 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx index 68906e2927a0da..22847843826da2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx @@ -7,6 +7,7 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; import { KibanaLogic } from '../../../shared/kibana'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -47,6 +48,7 @@ export const AnalyticsLayout: React.FC = ({ {children} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts index cb3273cc69387f..59e33893a18eb7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts @@ -30,6 +30,11 @@ describe('AnalyticsLogic', () => { dataLoading: true, analyticsUnavailable: false, allTags: [], + recentQueries: [], + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], totalQueries: 0, totalQueriesNoResults: 0, totalClicks: 0, @@ -38,6 +43,7 @@ describe('AnalyticsLogic', () => { queriesNoResultsPerDay: [], clicksPerDay: [], queriesPerDayForQuery: [], + topClicksForQuery: [], startDate: '', }; @@ -130,16 +136,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalClicks: 1000, - totalQueries: 5000, - totalQueriesNoResults: 500, - queriesPerDay: [10, 50, 100], - queriesNoResultsPerDay: [1, 2, 3], - clicksPerDay: [0, 10, 50], - // TODO: Replace this with ...MOCK_ANALYTICS_RESPONSE once all data is set + ...MOCK_ANALYTICS_RESPONSE, }); }); }); @@ -152,12 +149,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalQueriesForQuery: 50, - queriesPerDayForQuery: [25, 0, 25], - // TODO: Replace this with ...MOCK_QUERY_RESPONSE once all data is set + ...MOCK_QUERY_RESPONSE, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts index 537de02a0fee5c..0caf804ea2a08f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts @@ -62,6 +62,36 @@ export const AnalyticsLogic = kea allTags, }, ], + recentQueries: [ + [], + { + onAnalyticsDataLoad: (_, { recentQueries }) => recentQueries, + }, + ], + topQueries: [ + [], + { + onAnalyticsDataLoad: (_, { topQueries }) => topQueries, + }, + ], + topQueriesNoResults: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoResults }) => topQueriesNoResults, + }, + ], + topQueriesNoClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoClicks }) => topQueriesNoClicks, + }, + ], + topQueriesWithClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesWithClicks }) => topQueriesWithClicks, + }, + ], totalQueries: [ 0, { @@ -110,6 +140,12 @@ export const AnalyticsLogic = kea queriesPerDayForQuery, }, ], + topClicksForQuery: [ + [], + { + onQueryDataLoad: (_, { topClicksForQuery }) => topClicksForQuery, + }, + ], startDate: [ '', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss new file mode 100644 index 00000000000000..f3c503d4b27cbc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss @@ -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. + */ + +.analyticsHeader { + flex-wrap: wrap; + + &__filters.euiPageHeaderSection { + width: 100%; + margin: $euiSizeM 0; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx index 6866a89687a741..e82c3aff701194 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx @@ -30,6 +30,8 @@ import { AnalyticsLogic } from '../'; import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; import { convertTagsToSelectOptions } from '../utils'; +import './analytics_header.scss'; + interface Props { title: string; } @@ -60,7 +62,7 @@ export const AnalyticsHeader: React.FC = ({ title }) => { const hasInvalidDateRange = startDate > endDate; return ( - + @@ -69,13 +71,13 @@ export const AnalyticsHeader: React.FC = ({ title }) => { - + - + - + = ({ title }) => { fullWidth /> - + { + const { navigateToUrl } = mockKibanaValues; + const preventDefault = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const wrapper = shallow(); + const setSearchValue = (value: string) => + wrapper.find(EuiFieldSearch).simulate('change', { target: { value } }); + + it('renders', () => { + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + }); + + it('updates searchValue state on input change', () => { + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual(''); + + setSearchValue('some-query'); + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('some-query'); + }); + + it('sends the user to the query detail page on search', () => { + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some-query' + ); + }); + + it('falls back to showing the "" query if searchValue is empty', () => { + setSearchValue(''); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/%22%22' // "" gets encoded + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx new file mode 100644 index 00000000000000..fc2639d87a2f93 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx @@ -0,0 +1,53 @@ +/* + * 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 } from 'react'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton, EuiSpacer } from '@elastic/eui'; + +import { KibanaLogic } from '../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../routes'; +import { generateEnginePath } from '../../engine'; + +export const AnalyticsSearch: React.FC = () => { + const [searchValue, setSearchValue] = useState(''); + + const { navigateToUrl } = useValues(KibanaLogic); + const viewQueryDetails = (e: React.SyntheticEvent) => { + e.preventDefault(); + const query = searchValue || '""'; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }; + + return ( +
    + + + setSearchValue(e.target.value)} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchPlaceholder', + { defaultMessage: 'Go to search term' } + )} + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchButtonLabel', + { defaultMessage: 'View details' } + )} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx new file mode 100644 index 00000000000000..1814aba7497f6b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx @@ -0,0 +1,24 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { AnalyticsSection } from './'; + +describe('AnalyticsSection', () => { + it('renders', () => { + const wrapper = shallow( + +
    Test
    +
    + ); + + expect(wrapper.find('h2').text()).toEqual('Lorem ipsum'); + expect(wrapper.find('p').text()).toEqual('Dolor sit amet.'); + expect(wrapper.find('[data-test-subj="HelloWorld"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx new file mode 100644 index 00000000000000..e14ef0b1f26318 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx @@ -0,0 +1,28 @@ +/* + * 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 { EuiPageContentBody, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface Props { + title: string; + subtitle: string; +} +export const AnalyticsSection: React.FC = ({ title, subtitle, children }) => ( +
    +
    + +

    {title}

    +
    + +

    {subtitle}

    +
    +
    + + {children} +
    +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx new file mode 100644 index 00000000000000..88f7e858bef62d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { AnalyticsTable } from './'; + +describe('AnalyticsTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + key: 'some search', + tags: ['tagA'], + searches: { doc_count: 100 }, + clicks: { doc_count: 10 }, + }, + { + key: 'another search', + tags: ['tagB'], + searches: { doc_count: 99 }, + clicks: { doc_count: 9 }, + }, + { + key: '', + tags: ['tagA', 'tagB'], + searches: { doc_count: 1 }, + clicks: { doc_count: 0 }, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Queries'); + expect(tableContent).toContain('100'); + expect(tableContent).toContain('99'); + expect(tableContent).toContain('1'); + expect(tableContent).not.toContain('Clicks'); + }); + + it('renders a clicks column if hasClicks is passed', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('9'); + expect(tableContent).toContain('0'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No queries were performed during this time period.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx new file mode 100644 index 00000000000000..41690dfe26e716 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx @@ -0,0 +1,76 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { Query } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: Query[]; + hasClicks?: boolean; +} +type Columns = Array>; + +export const AnalyticsTable: React.FC = ({ items, hasClicks }) => { + const TERM_COLUMN = { + field: 'key', + ...TERM_COLUMN_PROPS, + }; + + const COUNT_COLUMNS = [ + { + field: 'searches.doc_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.queriesColumn', + { defaultMessage: 'Queries' } + ), + ...COUNT_COLUMN_PROPS, + }, + ]; + if (hasClicks) { + COUNT_COLUMNS.push({ + field: 'clicks.doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + ...COUNT_COLUMN_PROPS, + }); + } + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesTitle', + { defaultMessage: 'No queries' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesDescription', + { defaultMessage: 'No queries were performed during this time period.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts new file mode 100644 index 00000000000000..99363c00caaf7c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { AnalyticsTable } from './analytics_table'; +export { RecentQueriesTable } from './recent_queries_table'; +export { QueryClicksTable } from './query_clicks_table'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx new file mode 100644 index 00000000000000..5909ceec4555c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { shallow } from 'enzyme'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { InlineTagsList } from './inline_tags_list'; + +describe('InlineTagsList', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiBadge)).toHaveLength(1); + expect(wrapper.find(EuiBadge).prop('children')).toEqual('test'); + }); + + it('renders >2 badges in a tooltip list', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiBadge)).toHaveLength(3); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + + expect(wrapper.find(EuiBadge).at(0).prop('children')).toEqual('1'); + expect(wrapper.find(EuiBadge).at(1).prop('children')).toEqual('2'); + expect(wrapper.find(EuiBadge).at(2).prop('children')).toEqual('and 3 more'); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('3, 4, 5'); + }); + + it('does not render with no tags', () => { + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx new file mode 100644 index 00000000000000..853f04ee1aa777 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx @@ -0,0 +1,44 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiBadgeGroup, EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { Query } from '../../types'; + +interface Props { + tags?: Query['tags']; +} +export const InlineTagsList: React.FC = ({ tags }) => { + if (!tags?.length) return null; + + const displayedTags = tags.slice(0, 2); + const tooltipTags = tags.slice(2); + + return ( + + {displayedTags.map((tag: string) => ( + + {tag} + + ))} + {tooltipTags.length > 0 && ( + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.moreTagsBadge', + { + defaultMessage: 'and {moreTagsCount} more', + values: { moreTagsCount: tooltipTags.length }, + } + )} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx new file mode 100644 index 00000000000000..9db9c140d7f504 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx @@ -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 { mountWithIntl } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiLink, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { QueryClicksTable } from './'; + +describe('QueryClicksTable', () => { + const items = [ + { + key: 'some-document', + document: { + engine: 'some-engine', + id: 'some-document', + }, + tags: ['tagA'], + doc_count: 10, + }, + { + key: 'another-document', + document: { + engine: 'another-engine', + id: 'another-document', + }, + tags: ['tagB'], + doc_count: 5, + }, + { + key: 'deleted-document', + tags: [], + doc_count: 1, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Documents'); + expect(tableContent).toContain('some-document'); + expect(tableContent).toContain('another-document'); + expect(tableContent).toContain('deleted-document'); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual( + '/app/enterprise_search/engines/some-engine/documents/some-document' + ); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual( + '/app/enterprise_search/engines/another-engine/documents/another-document' + ); + // deleted-document should not have a link + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(2); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('5'); + expect(tableContent).toContain('1'); + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No clicks'); + expect(promptContent).toContain('No documents have been clicked from this query.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx new file mode 100644 index 00000000000000..e032e42eca3a62 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx @@ -0,0 +1,78 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; +import { DOCUMENTS_TITLE } from '../../../documents'; + +import { QueryClick } from '../../types'; +import { FIRST_COLUMN_PROPS, TAGS_COLUMN, COUNT_COLUMN_PROPS } from './shared_columns'; + +interface Props { + items: QueryClick[]; +} +type Columns = Array>; + +export const QueryClicksTable: React.FC = ({ items }) => { + const DOCUMENT_COLUMN = { + ...FIRST_COLUMN_PROPS, + field: 'document', + name: DOCUMENTS_TITLE, + render: (document: QueryClick['document'], query: QueryClick) => { + return document ? ( + + {document.id} + + ) : ( + query.key + ); + }, + }; + + const CLICKS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + }; + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksTitle', + { defaultMessage: 'No clicks' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksDescription', + { defaultMessage: 'No documents have been clicked from this query.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx new file mode 100644 index 00000000000000..261d0f75c1cee1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQueriesTable } from './'; + +describe('RecentQueriesTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + query_string: 'some search', + timestamp: '1970-01-03T12:00:00Z', + tags: ['tagA'], + document_ids: ['documentA', 'documentB'], + }, + { + query_string: 'another search', + timestamp: '1970-01-02T12:00:00Z', + tags: ['tagB'], + document_ids: ['documentC'], + }, + { + query_string: '', + timestamp: '1970-01-01T12:00:00Z', + tags: ['tagA', 'tagB'], + document_ids: ['documentA', 'documentB', 'documentC'], + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Time'); + expect(tableContent).toContain('1/3/1970'); + expect(tableContent).toContain('1/2/1970'); + expect(tableContent).toContain('1/1/1970'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Results'); + expect(tableContent).toContain('2'); + expect(tableContent).toContain('1'); + expect(tableContent).toContain('3'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No recent queries'); + expect(promptContent).toContain('Queries will appear here as they are received.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx new file mode 100644 index 00000000000000..b0dc8254c084b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx @@ -0,0 +1,82 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedDate, FormattedTime } from '@kbn/i18n/react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQuery } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: RecentQuery[]; +} +type Columns = Array>; + +export const RecentQueriesTable: React.FC = ({ items }) => { + const TERM_COLUMN = { + ...TERM_COLUMN_PROPS, + field: 'query_string', + }; + + const TIME_COLUMN = { + field: 'timestamp', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.timeColumn', { + defaultMessage: 'Time', + }), + render: (timestamp: RecentQuery['timestamp']) => { + const date = new Date(timestamp); + return ( + <> + + + ); + }, + width: '175px', + }; + + const RESULTS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'document_ids', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.resultsColumn', { + defaultMessage: 'Results', + }), + render: (documents: RecentQuery['document_ids']) => documents.length, + }; + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesTitle', + { defaultMessage: 'No recent queries' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesDescription', + { defaultMessage: 'Queries will appear here as they are received.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx new file mode 100644 index 00000000000000..16743405e0b5e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; + +import { Query, RecentQuery } from '../../types'; +import { InlineTagsList } from './inline_tags_list'; + +/** + * Shared columns / column properties between separate analytics tables + */ + +export const FIRST_COLUMN_PROPS = { + truncateText: true, + width: '25%', + mobileOptions: { + enlarge: true, + width: '100%', + }, +}; + +export const TERM_COLUMN_PROPS = { + // Field key changes per-table + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.termColumn', { + defaultMessage: 'Search term', + }), + render: (query: Query['key']) => { + if (!query) query = '""'; + return ( + + {query} + + ); + }, + ...FIRST_COLUMN_PROPS, +}; + +export const ACTIONS_COLUMN = { + width: '120px', + actions: [ + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAction', { + defaultMessage: 'View', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.viewTooltip', + { defaultMessage: 'View query analytics' } + ), + type: 'icon', + icon: 'popout', + color: 'primary', + onClick: (item: Query | RecentQuery) => { + const { navigateToUrl } = KibanaLogic.values; + + const query = (item as Query).key || (item as RecentQuery).query_string; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }, + 'data-test-subj': 'AnalyticsTableViewQueryButton', + }, + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.editAction', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip', + { defaultMessage: 'Edit query analytics' } + ), + type: 'icon', + icon: 'pencil', + onClick: () => { + // TODO: CurationsLogic + }, + 'data-test-subj': 'AnalyticsTableEditQueryButton', + }, + ], +}; + +export const TAGS_COLUMN = { + field: 'tags', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsColumn', { + defaultMessage: 'Analytics tags', + }), + truncateText: true, + render: (tags: Query['tags']) => , +}; + +export const COUNT_COLUMN_PROPS = { + dataType: 'number', + width: '100px', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts index ae9c9ca4506382..ddad726b04c260 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts @@ -7,4 +7,7 @@ export { AnalyticsCards } from './analytics_cards'; export { AnalyticsChart } from './analytics_chart'; export { AnalyticsHeader } from './analytics_header'; +export { AnalyticsSection } from './analytics_section'; +export { AnalyticsSearch } from './analytics_search'; +export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables'; export { AnalyticsUnavailable } from './analytics_unavailable'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts index a3977a0c07a803..8bee8fd4407b7f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts @@ -4,27 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -interface Query { - doc_count: number; +export interface Query { key: string; - clicks?: { doc_count: number }; - searches?: { doc_count: number }; tags?: string[]; + searches?: { doc_count: number }; + clicks?: { doc_count: number }; } -interface QueryClick extends Query { +export interface QueryClick extends Query { document?: { id: string; engine: string; - tags?: string[]; }; } -interface RecentQuery { - document_ids: string[]; +export interface RecentQuery { query_string: string; - tags: string[]; timestamp: string; + tags: string[]; + document_ids: string[]; } /** diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx index 06bf77d35372fe..e5bff981cb000b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx @@ -5,12 +5,19 @@ */ import { setMockValues } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { AnalyticsCards, AnalyticsChart } from '../components'; -import { Analytics } from './'; +import { + AnalyticsCards, + AnalyticsChart, + AnalyticsSection, + AnalyticsTable, + RecentQueriesTable, +} from '../components'; +import { Analytics, ViewAllButton } from './analytics'; describe('Analytics overview', () => { it('renders', () => { @@ -22,10 +29,27 @@ describe('Analytics overview', () => { queriesNoResultsPerDay: [1, 2, 3], clicksPerDay: [0, 1, 5], startDate: '1970-01-01', + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], + recentQueries: [], }); const wrapper = shallow(); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(AnalyticsSection)).toHaveLength(3); + expect(wrapper.find(AnalyticsTable)).toHaveLength(4); + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); + }); + + describe('ViewAllButton', () => { + it('renders', () => { + const to = '/analytics/top_queries'; + const wrapper = shallow(); + + expect(wrapper.prop('to')).toEqual(to); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx index d3c3bff5a29471..e6a3e1ca5809b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx @@ -7,15 +7,32 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { + ENGINE_ANALYTICS_TOP_QUERIES_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH, + ENGINE_ANALYTICS_RECENT_QUERIES_PATH, +} from '../../../routes'; +import { generateEnginePath } from '../../engine'; import { ANALYTICS_TITLE, TOTAL_QUERIES, TOTAL_QUERIES_NO_RESULTS, TOTAL_CLICKS, + TOP_QUERIES, + TOP_QUERIES_NO_RESULTS, + TOP_QUERIES_WITH_CLICKS, + TOP_QUERIES_NO_CLICKS, + RECENT_QUERIES, } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; export const Analytics: React.FC = () => { @@ -27,6 +44,11 @@ export const Analytics: React.FC = () => { queriesNoResultsPerDay, clicksPerDay, startDate, + topQueries, + topQueriesNoResults, + topQueriesWithClicks, + topQueriesNoClicks, + recentQueries, } = useValues(AnalyticsLogic); return ( @@ -72,7 +94,77 @@ export const Analytics: React.FC = () => { /> -

    TODO: Analytics overview

    + + +

    {TOP_QUERIES}

    +
    + + + + +

    {TOP_QUERIES_NO_RESULTS}

    +
    + + +
    + + + + +

    {TOP_QUERIES_WITH_CLICKS}

    +
    + + + + +

    {TOP_QUERIES_NO_CLICKS}

    +
    + + +
    + + + + + + ); }; + +export const ViewAllButton: React.FC<{ to: string }> = ({ to }) => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAllButtonLabel', { + defaultMessage: 'View all', + })} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx index 99485340f6b885..7705d342ecdce5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { AnalyticsCards, AnalyticsChart } from '../components'; +import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components'; import { QueryDetail } from './'; describe('QueryDetail', () => { @@ -41,5 +41,6 @@ describe('QueryDetail', () => { expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(QueryClicksTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx index 53c1dc8b845b12..d5d864f35f6819 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx @@ -15,6 +15,7 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_c import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, QueryClicksTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; const QUERY_DETAIL_TITLE = i18n.translate( @@ -28,7 +29,9 @@ interface Props { export const QueryDetail: React.FC = ({ breadcrumbs }) => { const { query } = useParams() as { query: string }; - const { totalQueriesForQuery, queriesPerDayForQuery, startDate } = useValues(AnalyticsLogic); + const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues( + AnalyticsLogic + ); return ( @@ -63,7 +66,18 @@ export const QueryDetail: React.FC = ({ breadcrumbs }) => { /> -

    TODO: Query detail page

    + + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx index f25b044e8a56fc..efd2de9223c980 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { RecentQueriesTable } from '../components'; import { RecentQueries } from './'; describe('RecentQueries', () => { it('renders', () => { + setMockValues({ recentQueries: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx index 3510a2a0e82210..708863ba0e5c89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { RECENT_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, RecentQueriesTable } from '../components'; +import { AnalyticsLogic } from '../'; export const RecentQueries: React.FC = () => { + const { recentQueries } = useValues(AnalyticsLogic); + return ( -

    TODO: Recent queries

    + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx index 9747609aaf0664..754a349c2fe944 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueries } from './'; describe('TopQueries', () => { it('renders', () => { + setMockValues({ topQueries: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx index 3f2867871765ca..0814ba16e39dca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueries: React.FC = () => { + const { topQueries } = useValues(AnalyticsLogic); + return ( -

    TODO: Top queries

    + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx index bc55753acf1524..f1eb3a2f69a98e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoClicks } from './'; describe('TopQueriesNoClicks', () => { it('renders', () => { + setMockValues({ topQueriesNoClicks: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx index dc14c4a83bff30..283a790b615719 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoClicks: React.FC = () => { + const { topQueriesNoClicks } = useValues(AnalyticsLogic); + return ( -

    TODO: Top queries with no clicks

    + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx index 72c718f3747141..8e404e34b5f3e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoResults } from './'; describe('TopQueriesNoResults', () => { it('renders', () => { + setMockValues({ topQueriesNoResults: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx index da8595b43859f3..8a54d529b2dd00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_RESULTS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoResults: React.FC = () => { + const { topQueriesNoResults } = useValues(AnalyticsLogic); + return ( -

    TODO: Top queries with no results

    + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx index 74e31e77974ee1..714da0d8e45dd0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesWithClicks } from './'; describe('TopQueriesWithClicks', () => { it('renders', () => { + setMockValues({ topQueriesWithClicks: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx index dc6e837be61d8f..73ad9e2e973d82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_WITH_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesWithClicks: React.FC = () => { + const { topQueriesWithClicks } = useValues(AnalyticsLogic); + return ( -

    TODO: Top queries with clicks

    + +
    ); }; From f53bc9825be973ed445d2040f4877cdeaabc8a6e Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 29 Jan 2021 14:48:55 -0500 Subject: [PATCH 25/38] [ML] Data Frame Analytics creation: improve existing job check (#89627) * use jobsExist endpoint instead of preloaded job list * remove unused translation * memoize jobCheck so cancel call works correctly --- .../create_analytics_advanced_editor.tsx | 41 ++++++++++++++++++- .../details_step/details_step_form.tsx | 32 ++++++++++++++- .../use_create_analytics_form/reducer.ts | 10 +---- .../use_create_analytics_form.ts | 28 +------------ .../ml_api_service/data_frame_analytics.ts | 13 ++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 7 files changed, 85 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index a35a314bec985c..0be9e00b70f935 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useRef } from 'react'; - +import React, { FC, Fragment, useEffect, useMemo, useRef } from 'react'; +import { debounce } from 'lodash'; import { EuiCallOut, EuiCodeEditor, @@ -22,6 +22,9 @@ import { XJsonMode } from '../../../../../../../shared_imports'; const xJsonMode = new XJsonMode(); +import { useNotifications } from '../../../../../contexts/kibana'; +import { ml } from '../../../../../services/ml_api_service'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { CreateStep } from '../create_step'; import { ANALYTICS_STEPS } from '../../page'; @@ -42,11 +45,33 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop } = state.form; const forceInput = useRef(null); + const { toasts } = useNotifications(); const onChange = (str: string) => { setAdvancedEditorRawString(str); }; + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + toasts.addDanger( + i18n.translate( + 'xpack.ml.dataframe.analytics.create.advancedEditor.errorCheckingJobIdExists', + { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + } + ) + ); + } + }, 400), + [jobId] + ); + // Temp effect to close the context menu popover on Clone button click useEffect(() => { if (forceInput.current === null) { @@ -57,6 +82,18 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop forceInput.current.dispatchEvent(evt); }, []); + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + return ( = ({ } }, 400); + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingJobIdExists', { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + }) + ); + } + }, 400), + [jobId] + ); + + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + useEffect(() => { if (destinationIndexNameValid === true) { debouncedIndexCheck(); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index a277ae6e6a66e6..998460d75f6f07 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -499,7 +499,6 @@ export function reducer(state: State, action: Action): State { } if (action.payload.jobId !== undefined) { - newFormState.jobIdExists = state.jobIds.some((id) => newFormState.jobId === id); newFormState.jobIdEmpty = newFormState.jobId === ''; newFormState.jobIdValid = isJobIdValid(newFormState.jobId); newFormState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)( @@ -542,12 +541,6 @@ export function reducer(state: State, action: Action): State { case ACTION.SET_JOB_CONFIG: return validateAdvancedEditor({ ...state, jobConfig: action.payload }); - case ACTION.SET_JOB_IDS: { - const newState = { ...state, jobIds: action.jobIds }; - newState.form.jobIdExists = newState.jobIds.some((id) => newState.form.jobId === id); - return newState; - } - case ACTION.SWITCH_TO_ADVANCED_EDITOR: const jobConfig = getJobConfigFromFormState(state.form); const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); @@ -562,7 +555,7 @@ export function reducer(state: State, action: Action): State { }); case ACTION.SWITCH_TO_FORM: - const { jobConfig: config, jobIds } = state; + const { jobConfig: config } = state; const { jobId } = state.form; // @ts-ignore const formState = getFormStateFromJobConfig(config, false); @@ -571,7 +564,6 @@ export function reducer(state: State, action: Action): State { formState.jobId = jobId; } - formState.jobIdExists = jobIds.some((id) => formState.jobId === id); formState.jobIdEmpty = jobId === ''; formState.jobIdValid = isJobIdValid(jobId); formState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(jobId); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 0b88f52e555c0b..f5bfd3075f26bd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -14,11 +14,7 @@ import { ml } from '../../../../../services/ml_api_service'; import { useMlContext } from '../../../../../contexts/ml'; import { DuplicateIndexPatternError } from '../../../../../../../../../../src/plugins/data/public'; -import { - useRefreshAnalyticsList, - DataFrameAnalyticsId, - DataFrameAnalyticsConfig, -} from '../../../../common'; +import { useRefreshAnalyticsList, DataFrameAnalyticsConfig } from '../../../../common'; import { extractCloningConfig, isAdvancedConfig } from '../../components/action_clone'; import { ActionDispatchers, ACTION } from './actions'; @@ -80,9 +76,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SET_IS_JOB_STARTED, isJobStarted }); }; - const setJobIds = (jobIds: DataFrameAnalyticsId[]) => - dispatch({ type: ACTION.SET_JOB_IDS, jobIds }); - const resetRequestMessages = () => dispatch({ type: ACTION.RESET_REQUEST_MESSAGES }); const resetForm = () => dispatch({ type: ACTION.RESET_FORM }); @@ -180,25 +173,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { }; const prepareFormValidation = async () => { - // re-fetch existing analytics job IDs and indices for form validation - try { - setJobIds( - (await ml.dataFrameAnalytics.getDataFrameAnalytics()).data_frame_analytics.map( - (job: DataFrameAnalyticsConfig) => job.id - ) - ); - } catch (e) { - addRequestMessage({ - error: extractErrorMessage(e), - message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList', - { - defaultMessage: 'An error occurred getting the existing data frame analytics job IDs:', - } - ), - }); - } - try { // Set the existing index pattern titles. const indexPatternsMap: SourceIndexMap = {}; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 298dcad4ce488d..7b246e557d7a57 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -45,6 +45,11 @@ interface DeleteDataFrameAnalyticsWithIndexResponse { destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus; destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus; } +interface JobsExistsResponse { + results: { + [jobId: string]: boolean; + }; +} export const dataFrameAnalytics = { getDataFrameAnalytics(analyticsId?: string) { @@ -98,6 +103,14 @@ export const dataFrameAnalytics = { query: { treatAsRoot, type }, }); }, + jobsExists(analyticsIds: string[], allSpaces: boolean = false) { + const body = JSON.stringify({ analyticsIds, allSpaces }); + return http({ + path: `${basePath()}/data_frame/analytics/jobs_exist`, + method: 'POST', + body, + }); + }, evaluateDataFrameAnalytics(evaluateConfig: any) { const body = JSON.stringify(evaluateConfig); return http({ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 28ef79beb72cfd..d0634d6cd87a26 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12595,7 +12595,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "既存のインデックス名の取得中に次のエラーが発生しました:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "データフレーム分析ジョブの開始中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "縮小が重みに適用されました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 052a00b1aefa48..4ca6d11aa8940f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12624,7 +12624,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "获取现有索引名称时发生以下错误:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "缩小量已应用于权重", From 5a33872e07a6a7e59f08cdbe28798a2c99cb1dae Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 29 Jan 2021 15:09:33 -0500 Subject: [PATCH 26/38] [CI] Sleep before starting ciGroup tasks to smooth out CPU spikes from ES starting up (#89751) --- vars/kibanaPipeline.groovy | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 93cb7a719bbe8c..3e72c9e059af80 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -130,6 +130,8 @@ def functionalTestProcess(String name, String script) { def ossCiGroupProcess(ciGroup) { return functionalTestProcess("ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup + withEnv([ "CI_GROUP=${ciGroup}", "JOB=kibana-ciGroup${ciGroup}", @@ -143,6 +145,7 @@ def ossCiGroupProcess(ciGroup) { def xpackCiGroupProcess(ciGroup) { return functionalTestProcess("xpack-ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup withEnv([ "CI_GROUP=${ciGroup}", "JOB=xpack-kibana-ciGroup${ciGroup}", @@ -454,6 +457,7 @@ def allCiTasks() { } def pipelineLibraryTests() { + return whenChanged(['vars/', '.ci/pipeline-library/']) { workers.base(size: 'flyweight', bootstrapped: false, ramDisk: false) { dir('.ci/pipeline-library') { From 4e18fd8a5170a2b0649aab848f75f4405cc4ceb9 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Fri, 29 Jan 2021 15:15:49 -0500 Subject: [PATCH 27/38] uptime adjust useBarCharts logic (#89628) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../waterfall/components/use_bar_charts.test.tsx | 7 +++++-- .../synthetics/waterfall/components/use_bar_charts.ts | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx index 28b74c5affbdf1..b3d20a6acd3e38 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx @@ -59,10 +59,13 @@ describe('useBarChartsHooks', () => { const firstChartItems = result.current[0]; const lastChartItems = result.current[4]; - // first chart items last item should be x 199, since we only display 150 items + // first chart items last item should be x 149, since we only display 150 items expect(firstChartItems[firstChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS - 1); - // since here are 5 charts, last chart first item should be x 800 + // first chart will only contain x values from 0 - 149; + expect(firstChartItems.find((item) => item.x > 149)).toBe(undefined); + + // since here are 5 charts, last chart first item should be x 600 expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4); expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts index 3345b30f5239f5..7beb0be28902b8 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts @@ -17,17 +17,16 @@ export const useBarCharts = ({ data = [] }: UseBarHookProps) => { useEffect(() => { if (data.length > 0) { - let chartIndex = 1; + let chartIndex = 0; - const firstCanvasItems = data.filter((item) => item.x <= CANVAS_MAX_ITEMS); - - const chartsN: Array = [firstCanvasItems]; + const chartsN: Array = []; data.forEach((item) => { // Subtract 1 to account for x value starting from 0 if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) { - chartsN.push([]); + chartsN.push([item]); chartIndex++; + return; } chartsN[chartIndex - 1].push(item); }); From e866db7de011d8a3171ae85b73642c703b205274 Mon Sep 17 00:00:00 2001 From: Vadim Yakhin Date: Fri, 29 Jan 2021 16:31:06 -0400 Subject: [PATCH 28/38] Migrate security page (#89720) * Add server routes for Workplace Search Security page * Initial copy/paste of component tree Also update lodash imports and fix default exports * Update paths * Remove conditional and passed in flash messages This is no longer needed with the Kibana syntax. Flash messages are set globally and only render when present. * Replace removed ConfirmModal In Kibana, we use the Eui components directly * Remove legacy AppView and sidenav * Clear flash messages globally * Update server routes * Replace Rails http with kibana http * Add setSourceRestriction action to app_logic It is used in security_logic * Add missing typings * Add route and update nav * Use internal tools for determining license * Remove Prompt as it doesn't work in Kibana There is an error that recommends using AppMountParameters.onAppLeave instead, but it doesn't cover the case where a user navigates within the app. We'll revisit this problem later. * Add i18n Also refactor PrivateSourcesTable to use static i18n strings. Before we were using 'remote' and 'standard' as both enums and parts of copy, i.e. "Enable {sourceType} private sources". But with i18n we can no longer do this. So I made a refactoring to separate these concerns. Now 'remote' and 'standard' are only used as enums. What i18n string to show is defined based on isRemote variable. * Add components unit tests * Add logic unit tests * Remove redundant imports * Use nextTick instead of awaiting for promises * Update logic tests to use new mockHelpers --- .../workplace_search/app_logic.ts | 6 + .../components/layout/nav.tsx | 4 +- .../workplace_search/constants.ts | 117 +++++++++++ .../applications/workplace_search/index.tsx | 7 + .../components/private_sources_table.test.tsx | 54 +++++ .../components/private_sources_table.tsx | 182 ++++++++++++++++ .../workplace_search/views/security/index.ts | 7 + .../views/security/security.test.tsx | 112 ++++++++++ .../views/security/security.tsx | 196 ++++++++++++++++++ .../views/security/security_logic.test.ts | 169 +++++++++++++++ .../views/security/security_logic.ts | 181 ++++++++++++++++ .../server/routes/workplace_search/index.ts | 2 + .../routes/workplace_search/security.test.ts | 108 ++++++++++ .../routes/workplace_search/security.ts | 78 +++++++ 14 files changed, 1220 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f5f534807fabfc..2ce7eed2368402 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -21,6 +21,7 @@ interface AppValues extends WorkplaceSearchInitialData { interface AppActions { initializeAppData(props: InitialAppData): InitialAppData; setContext(isOrganization: boolean): boolean; + setSourceRestriction(canCreatePersonalSources: boolean): boolean; } const emptyOrg = {} as Organization; @@ -34,6 +35,7 @@ export const AppLogic = kea>({ isFederatedAuth, }), setContext: (isOrganization) => isOrganization, + setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources, }, reducers: { hasInitialized: [ @@ -64,6 +66,10 @@ export const AppLogic = kea>({ emptyAccount, { initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount, + setSourceRestriction: (state, canCreatePersonalSources) => ({ + ...state, + canCreatePersonalSources, + }), }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 8a83e9aad5fd9b..7357e84f27a417 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -45,9 +45,7 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.ROLE_MAPPINGS} - - {NAV.SECURITY} - + {NAV.SECURITY} {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index e72e28aa47d9b8..17fbbf517f3473 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -289,6 +289,87 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate( } ); +export const PRIVATE_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSources.description', + { + defaultMessage: + 'Private sources are connected by users in your organization to create a personalized search experience.', + } +); + +export const PRIVATE_SOURCES_TOGGLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesToggle.description', + { + defaultMessage: 'Enable private sources for your organization', + } +); + +export const REMOTE_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesToggle.text', + { + defaultMessage: 'Enable remote private sources', + } +); + +export const REMOTE_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesTable.description', + { + defaultMessage: + 'Remote sources synchronize and store a limited amount of data on disk, with a low impact on storage resources.', + } +); + +export const REMOTE_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.title', + { + defaultMessage: 'No remote private sources configured yet', + } +); + +export const STANDARD_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesToggle.text', + { + defaultMessage: 'Enable standard private sources', + } +); + +export const STANDARD_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesTable.description', + { + defaultMessage: + 'Standard sources synchronize and store all searchable data on disk, with a directly correlated impact on storage resources.', + } +); + +export const STANDARD_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.title', + { + defaultMessage: 'No standard private sources configured yet', + } +); + +export const SECURITY_UNSAVED_CHANGES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.unsavedChanges.message', + { + defaultMessage: + 'Your private sources settings have not been saved. Are you sure you want to leave?', + } +); + +export const PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesUpdateConfirmation.text', + { + defaultMessage: 'Updates to private source configuration will take effect immediately.', + } +); + +export const SOURCE_RESTRICTIONS_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.sourceRestrictionsSuccess.message', + { + defaultMessage: 'Successfully updated source restrictions.', + } +); + export const PUBLIC_KEY_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.publicKey.label', { @@ -382,6 +463,20 @@ export const SAVE_CHANGES_BUTTON = i18n.translate( } ); +export const SAVE_SETTINGS_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.saveSettings.button', + { + defaultMessage: 'Save settings', + } +); + +export const KEEP_EDITING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.keepEditing.button', + { + defaultMessage: 'Keep editing', + } +); + export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', { defaultMessage: 'Name', }); @@ -493,6 +588,10 @@ export const UPDATE_BUTTON = i18n.translate( } ); +export const RESET_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.reset.button', { + defaultMessage: 'Reset', +}); + export const CONFIGURE_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.configure.button', { @@ -522,6 +621,10 @@ export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( } ); +export const SOURCE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.source.text', { + defaultMessage: 'Source', +}); + export const PRIVATE_SOURCE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.privateSource.text', { @@ -529,6 +632,20 @@ export const PRIVATE_SOURCE = i18n.translate( } ); +export const PRIVATE_SOURCES = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privateSources.text', + { + defaultMessage: 'Private Sources', + } +); + +export const CONFIRM_CHANGES_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text', + { + defaultMessage: 'Confirm changes', + } +); + export const CONNECTORS_HEADER_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.connectors.header.title', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d10de7a7701711..ec1b8cfcba958f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -22,6 +22,7 @@ import { SOURCES_PATH, PERSONAL_SOURCES_PATH, ORG_SETTINGS_PATH, + SECURITY_PATH, } from './routes'; import { SetupGuide } from './views/setup_guide'; @@ -29,6 +30,7 @@ import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; +import { Security } from './views/security'; import { SourcesRouter } from './views/content_sources'; import { SettingsRouter } from './views/settings'; @@ -102,6 +104,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } restrictWidth readOnlyMode={readOnlyMode}> + + + } />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx new file mode 100644 index 00000000000000..4db5c60d5800d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx @@ -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. + */ + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch } from '@elastic/eui'; + +import { PrivateSourcesTable } from './private_sources_table'; + +describe('PrivateSourcesTable', () => { + beforeEach(() => { + setMockValues({ hasPlatinumLicense: true, isEnabled: true }); + }); + + const props = { + sourceSection: { isEnabled: true, contentSources: [] }, + updateSource: jest.fn(), + updateEnabled: jest.fn(), + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + }); + + it('handles switches clicks', () => { + const wrapper = shallow( + + ); + + const sectionSwitch = wrapper.find(EuiSwitch).first(); + const sourceSwitch = wrapper.find(EuiSwitch).last(); + + const event = { target: { value: true } }; + sectionSwitch.prop('onChange')(event as any); + sourceSwitch.prop('onChange')(event as any); + + expect(props.updateEnabled).toHaveBeenCalled(); + expect(props.updateSource).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx new file mode 100644 index 00000000000000..c767dfaba86f94 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx @@ -0,0 +1,182 @@ +/* + * 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 classNames from 'classnames'; +import { useValues } from 'kea'; + +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableRow, + EuiTableRowCell, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { LicensingLogic } from '../../../../shared/licensing'; +import { SecurityLogic, PrivateSourceSection } from '../security_logic'; +import { + REMOTE_SOURCES_TOGGLE_TEXT, + REMOTE_SOURCES_TABLE_DESCRIPTION, + REMOTE_SOURCES_EMPTY_TABLE_TITLE, + STANDARD_SOURCES_TOGGLE_TEXT, + STANDARD_SOURCES_TABLE_DESCRIPTION, + STANDARD_SOURCES_EMPTY_TABLE_TITLE, + SOURCE, +} from '../../../constants'; + +interface PrivateSourcesTableProps { + sourceType: 'remote' | 'standard'; + sourceSection: PrivateSourceSection; + updateSource(sourceId: string, isEnabled: boolean): void; + updateEnabled(isEnabled: boolean): void; +} + +const REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.enabledStrong', + { defaultMessage: 'enabled by default' } + )} + + ), + }} + /> +); + +const STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.notEnabledStrong', + { defaultMessage: 'not enabled by default' } + )} + + ), + }} + /> +); + +export const PrivateSourcesTable: React.FC = ({ + sourceType, + sourceSection: { isEnabled: sectionEnabled, contentSources }, + updateSource, + updateEnabled, +}) => { + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { isEnabled } = useValues(SecurityLogic); + + const isRemote = sourceType === 'remote'; + const hasSources = contentSources.length > 0; + const panelDisabled = !isEnabled || !hasPlatinumLicense; + const sectionDisabled = !sectionEnabled; + + const panelClass = classNames('euiPanel--outline euiPanel--noShadow', { + 'euiPanel--disabled': panelDisabled, + }); + + const tableClass = classNames({ 'euiTable--disabled': sectionDisabled }); + + const emptyState = ( + <> + + + + + {isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE} + + + + {isRemote + ? REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION + : STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION} + + + + ); + + const sectionHeading = ( + + + + updateEnabled(e.target.checked)} + disabled={!isEnabled || !hasPlatinumLicense} + showLabel={false} + label={`${sourceType} Sources Toggle`} + data-test-subj={`${sourceType}EnabledToggle`} + compressed + /> + + + +

    {isRemote ? REMOTE_SOURCES_TOGGLE_TEXT : STANDARD_SOURCES_TOGGLE_TEXT}

    +
    + + {isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION} + + {!hasSources && emptyState} +
    +
    + ); + + const sourcesTable = ( + <> + + + + {SOURCE} + + + + {contentSources.map((source, i) => ( + + {source.name} + + updateSource(source.id, e.target.checked)} + showLabel={false} + label={`${source.name} Toggle`} + data-test-subj={`${sourceType}SourceToggle`} + compressed + /> + + + ))} + + + + ); + + return ( + + {sectionHeading} + {hasSources && sourcesTable} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts new file mode 100644 index 00000000000000..a2db1bbc15a152 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts @@ -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 { Security } from './security'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx new file mode 100644 index 00000000000000..bca0d5edc32d60 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { setMockValues, setMockActions } from '../../../__mocks__'; +import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch, EuiConfirmModal } from '@elastic/eui'; +import { Loading } from '../../../shared/loading'; + +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { Security } from './security'; + +describe('Security', () => { + const initializeSourceRestrictions = jest.fn(); + const updatePrivateSourcesEnabled = jest.fn(); + const updateRemoteEnabled = jest.fn(); + const updateRemoteSource = jest.fn(); + const updateStandardEnabled = jest.fn(); + const updateStandardSource = jest.fn(); + const saveSourceRestrictions = jest.fn(); + const resetState = jest.fn(); + + const mockValues = { + isEnabled: true, + remote: { isEnabled: true, contentSources: [] }, + standard: { isEnabled: true, contentSources: [] }, + dataLoading: false, + unsavedChanges: false, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + setMockActions({ + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + }); + }); + + it('renders on Basic license', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(true); + }); + + it('renders on Platinum license', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(false); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('handles window.onbeforeunload change', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(); + + expect(window.onbeforeunload!({} as any)).toEqual( + 'Your private sources settings have not been saved. Are you sure you want to leave?' + ); + }); + + it('handles window.onbeforeunload unmount', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(); + + unmountHandler(); + + expect(window.onbeforeunload).toEqual(null); + }); + + it('handles switch click', () => { + const wrapper = shallow(); + + const privateSourcesSwitch = wrapper.find(EuiSwitch); + const event = { target: { checked: true } }; + privateSourcesSwitch.prop('onChange')(event as any); + + expect(updatePrivateSourcesEnabled).toHaveBeenCalled(); + }); + + it('handles confirmModal submission', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + const wrapper = shallow(); + + const header = wrapper.find(ViewContentHeader).dive(); + header.find('[data-test-subj="SaveSettingsButton"]').prop('onClick')!({} as any); + const modal = wrapper.find(EuiConfirmModal); + modal.prop('onConfirm')!({} as any); + + expect(saveSourceRestrictions).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx new file mode 100644 index 00000000000000..41df1a1acc5156 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx @@ -0,0 +1,196 @@ +/* + * 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 classNames from 'classnames'; +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiSpacer, + EuiPanel, + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +import { LicensingLogic } from '../../../shared/licensing'; +import { FlashMessages } from '../../../shared/flash_messages'; +import { LicenseCallout } from '../../components/shared/license_callout'; +import { Loading } from '../../../shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { SecurityLogic } from './security_logic'; + +import { PrivateSourcesTable } from './components/private_sources_table'; + +import { + SECURITY_UNSAVED_CHANGES_MESSAGE, + RESET_BUTTON, + SAVE_SETTINGS_BUTTON, + SAVE_CHANGES_BUTTON, + KEEP_EDITING_BUTTON, + PRIVATE_SOURCES, + PRIVATE_SOURCES_DESCRIPTION, + PRIVATE_SOURCES_TOGGLE_DESCRIPTION, + PRIVATE_PLATINUM_LICENSE_CALLOUT, + CONFIRM_CHANGES_TEXT, + PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT, +} from '../../constants'; + +export const Security: React.FC = () => { + const [confirmModalVisible, setConfirmModalVisibility] = useState(false); + + const hideConfirmModal = () => setConfirmModalVisibility(false); + const showConfirmModal = () => setConfirmModalVisibility(true); + + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const { + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + } = useActions(SecurityLogic); + + const { isEnabled, remote, standard, dataLoading, unsavedChanges } = useValues(SecurityLogic); + + useEffect(() => { + initializeSourceRestrictions(); + }, []); + + useEffect(() => { + window.onbeforeunload = unsavedChanges ? () => SECURITY_UNSAVED_CHANGES_MESSAGE : null; + return () => { + window.onbeforeunload = null; + }; + }, [unsavedChanges]); + + if (dataLoading) return ; + + const panelClass = classNames('euiPanel--noShadow', { + 'euiPanel--disabled': !hasPlatinumLicense, + }); + + const savePrivateSources = () => { + saveSourceRestrictions(); + hideConfirmModal(); + }; + + const headerActions = ( + + + + {RESET_BUTTON} + + + + + {SAVE_SETTINGS_BUTTON} + + + + ); + + const header = ( + <> + + + + ); + + const allSourcesToggle = ( + + + + updatePrivateSourcesEnabled(e.target.checked)} + disabled={!hasPlatinumLicense} + showLabel={false} + label="Private Sources Toggle" + data-test-subj="PrivateSourcesToggle" + /> + + + +

    {PRIVATE_SOURCES_TOGGLE_DESCRIPTION}

    +
    +
    +
    +
    + ); + + const platinumLicenseCallout = ( + <> + + + + ); + + const sourceTables = ( + <> + + + + + + ); + + const confirmModal = ( + + + {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} + + + ); + + return ( + <> + + {header} + {allSourcesToggle} + {!hasPlatinumLicense && platinumLicenseCallout} + {sourceTables} + {confirmModalVisible && confirmModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts new file mode 100644 index 00000000000000..abb1308081f0ca --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { LogicMounter } from '../../../__mocks__/kea.mock'; +import { mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import { SecurityLogic } from './security_logic'; +import { nextTick } from '@kbn/test/jest'; + +describe('SecurityLogic', () => { + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(SecurityLogic); + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + const defaultValues = { + dataLoading: true, + cachedServerState: {}, + isEnabled: false, + remote: {}, + standard: {}, + unsavedChanges: true, + }; + + const serverProps = { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: true, + contentSources: [{ id: 'one_drive', name: 'OneDrive', isEnabled: true }], + }, + }; + + it('has expected default values', () => { + expect(SecurityLogic.values).toEqual(defaultValues); + }); + + describe('actions', () => { + it('setServerProps', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('setSourceRestrictionsUpdated', () => { + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('updatePrivateSourcesEnabled', () => { + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + + expect(SecurityLogic.values.isEnabled).toEqual(false); + }); + + it('updateRemoteEnabled', () => { + SecurityLogic.actions.updateRemoteEnabled(false); + + expect(SecurityLogic.values.remote.isEnabled).toEqual(false); + }); + + it('updateStandardEnabled', () => { + SecurityLogic.actions.updateStandardEnabled(false); + + expect(SecurityLogic.values.standard.isEnabled).toEqual(false); + }); + + it('updateRemoteSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateRemoteSource('gmail', false); + + expect(SecurityLogic.values.remote.contentSources[0].isEnabled).toEqual(false); + }); + + it('updateStandardSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateStandardSource('one_drive', false); + + expect(SecurityLogic.values.standard.contentSources[0].isEnabled).toEqual(false); + }); + }); + + describe('selectors', () => { + describe('unsavedChanges', () => { + it('returns true while loading', () => { + expect(SecurityLogic.values.unsavedChanges).toEqual(true); + }); + + it('returns false after loading', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.unsavedChanges).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('initializeSourceRestrictions', () => { + it('calls API and sets values', async () => { + const setServerPropsSpy = jest.spyOn(SecurityLogic.actions, 'setServerProps'); + http.get.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.initializeSourceRestrictions(); + + expect(http.get).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions' + ); + await nextTick(); + expect(setServerPropsSpy).toHaveBeenCalledWith(serverProps); + }); + + it('handles error', async () => { + http.get.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.initializeSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveSourceRestrictions', () => { + it('calls API and sets values', async () => { + http.patch.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + SecurityLogic.actions.saveSourceRestrictions(); + + expect(http.patch).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions', + { + body: JSON.stringify(serverProps), + } + ); + }); + + it('handles error', async () => { + http.patch.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.saveSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('resetState', () => { + it('calls API and sets values', async () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + SecurityLogic.actions.resetState(); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts new file mode 100644 index 00000000000000..df843b330d411f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts @@ -0,0 +1,181 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; + +import { kea, MakeLogicType } from 'kea'; + +import { + clearFlashMessages, + setSuccessMessage, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { AppLogic } from '../../app_logic'; + +import { SOURCE_RESTRICTIONS_SUCCESS_MESSAGE } from '../../constants'; + +export interface PrivateSource { + id: string; + name: string; + isEnabled: boolean; +} + +export interface PrivateSourceSection { + isEnabled: boolean; + contentSources: PrivateSource[]; +} + +export interface SecurityServerProps { + isEnabled: boolean; + remote: PrivateSourceSection; + standard: PrivateSourceSection; +} + +interface SecurityValues extends SecurityServerProps { + dataLoading: boolean; + unsavedChanges: boolean; + cachedServerState: SecurityServerProps; +} + +interface SecurityActions { + setServerProps(serverProps: SecurityServerProps): SecurityServerProps; + setSourceRestrictionsUpdated(serverProps: SecurityServerProps): SecurityServerProps; + initializeSourceRestrictions(): void; + saveSourceRestrictions(): void; + updatePrivateSourcesEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + updateStandardEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateStandardSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + resetState(): void; +} + +const route = '/api/workplace_search/org/security/source_restrictions'; + +export const SecurityLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'security_logic'], + actions: { + setServerProps: (serverProps: SecurityServerProps) => serverProps, + setSourceRestrictionsUpdated: (serverProps: SecurityServerProps) => serverProps, + initializeSourceRestrictions: () => true, + saveSourceRestrictions: () => null, + updatePrivateSourcesEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + updateStandardEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateStandardSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + resetState: () => null, + }, + reducers: { + dataLoading: [ + true, + { + setServerProps: () => false, + }, + ], + cachedServerState: [ + {} as SecurityServerProps, + { + setServerProps: (_, serverProps) => cloneDeep(serverProps), + setSourceRestrictionsUpdated: (_, serverProps) => cloneDeep(serverProps), + }, + ], + isEnabled: [ + false, + { + setServerProps: (_, { isEnabled }) => isEnabled, + setSourceRestrictionsUpdated: (_, { isEnabled }) => isEnabled, + updatePrivateSourcesEnabled: (_, { isEnabled }) => isEnabled, + }, + ], + remote: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { remote }) => remote, + setSourceRestrictionsUpdated: (_, { remote }) => remote, + updateRemoteEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateRemoteSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + standard: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { standard }) => standard, + setSourceRestrictionsUpdated: (_, { standard }) => standard, + updateStandardEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateStandardSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + }, + selectors: ({ selectors }) => ({ + unsavedChanges: [ + () => [ + selectors.cachedServerState, + selectors.isEnabled, + selectors.remote, + selectors.standard, + ], + (cached, isEnabled, remote, standard) => + cached.isEnabled !== isEnabled || + !isEqual(cached.remote, remote) || + !isEqual(cached.standard, standard), + ], + }), + listeners: ({ actions, values }) => ({ + initializeSourceRestrictions: async () => { + const { http } = HttpLogic.values; + + try { + const response = await http.get(route); + actions.setServerProps(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveSourceRestrictions: async () => { + const { isEnabled, remote, standard } = values; + const serverData = { isEnabled, remote, standard }; + const body = JSON.stringify(serverData); + const { http } = HttpLogic.values; + + try { + const response = await http.patch(route, { body }); + actions.setSourceRestrictionsUpdated(response); + setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE); + AppLogic.actions.setSourceRestriction(isEnabled); + } catch (e) { + flashAPIErrors(e); + } + }, + resetState: () => { + actions.setServerProps(cloneDeep(values.cachedServerState)); + clearFlashMessages(); + }, + }), +}); + +const updateSourceEnabled = ( + section: PrivateSourceSection, + id: string, + isEnabled: boolean +): PrivateSourceSection => { + const updatedSection = { ...section }; + const sources = updatedSection.contentSources; + const sourceIndex = sources.findIndex((source) => source.id === id); + updatedSection.contentSources[sourceIndex] = { ...sources[sourceIndex], isEnabled }; + + return updatedSection; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts index 99445108b315af..f2792be8e65359 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts @@ -10,10 +10,12 @@ import { registerOverviewRoute } from './overview'; import { registerGroupsRoutes } from './groups'; import { registerSourcesRoutes } from './sources'; import { registerSettingsRoutes } from './settings'; +import { registerSecurityRoutes } from './security'; export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => { registerOverviewRoute(dependencies); registerGroupsRoutes(dependencies); registerSourcesRoutes(dependencies); registerSettingsRoutes(dependencies); + registerSecurityRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts new file mode 100644 index 00000000000000..12f84278e9ead7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSecurityRoute, registerSecuritySourceRestrictionsRoute } from './security'; + +describe('security routes', () => { + describe('GET /api/workplace_search/org/security', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security', + }); + + registerSecurityRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security', + }); + }); + }); + + describe('GET /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + }); + + describe('PATCH /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'patch', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: false, + contentSources: [{ id: 'dropbox', name: 'Dropbox', isEnabled: false }], + }, + }, + }; + mockRouter.shouldValidate(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts new file mode 100644 index 00000000000000..0aa218dfc28839 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts @@ -0,0 +1,78 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSecurityRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security', + }) + ); +} + +export function registerSecuritySourceRestrictionsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); + + router.patch( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: { + body: schema.object({ + isEnabled: schema.boolean(), + remote: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + standard: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); +} + +export const registerSecurityRoutes = (dependencies: RouteDependencies) => { + registerSecurityRoute(dependencies); + registerSecuritySourceRestrictionsRoute(dependencies); +}; From df913b47bee8ccf0e836c5866ef6b4345004813d Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Fri, 29 Jan 2021 14:06:14 -0700 Subject: [PATCH 29/38] Update build_chromium README (#89762) * Update build_chromium README * more edits * Update init.py --- x-pack/build_chromium/README.md | 59 +++++++++++++++++++++------------ x-pack/build_chromium/build.py | 4 +-- x-pack/build_chromium/init.py | 12 ++++--- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 9934d06a9d96a5..39382620775ad7 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -6,50 +6,65 @@ to accept a commit hash from the Chromium repository, and initialize the build environments and run the build on Mac, Windows, and Linux. ## Before you begin + If you wish to use a remote VM to build, you'll need access to our GCP account, which is where we have two machines provisioned for the Linux and Windows builds. Mac builds can be achieved locally, and are a great place to start to gain familiarity. +**NOTE:** Linux builds should be done in Ubuntu on x86 architecture. ARM builds +are created in x86. CentOS is not supported for building Chromium. + 1. Login to our GCP instance [here using your okta credentials](https://console.cloud.google.com/). 2. Click the "Compute Engine" tab. -3. Ensure that `chromium-build-linux` and `chromium-build-windows-12-beefy` are there. -4. If #3 fails, you'll have to spin up new instances. Generally, these need `n1-standard-8` types or 8 vCPUs/30 GB memory. -5. Ensure that there's enough room left on the disk: 100GB is required. `ncdu` is a good linux util to verify what's claming space. - -## Usage +3. Find `chromium-build-linux` or `chromium-build-windows-12-beefy` and start the instance. +4. Install [Google Cloud SDK](https://cloud.google.com/sdk) locally to ssh into the GCP instance +5. System dependencies: + - 8 CPU + - 30GB memory + - 80GB free space on disk (Try `ncdu /home` to see where space is used.) + - git + - python2 (`python` must link to `python2`) + - lsb_release + - tmux is recommended in case your ssh session is interrupted +6. Copy the entire `build_chromium` directory into a GCP storage bucket, so you can copy the scripts into the instance and run them. + +## Build Script Usage ``` +# Allow our scripts to use depot_tools commands export PATH=$HOME/chromium/depot_tools:$PATH + # Create a dedicated working directory for this directory of Python scripts. mkdir ~/chromium && cd ~/chromium + # Copy the scripts from the Kibana repo to use them conveniently in the working directory -cp -r ~/path/to/kibana/x-pack/build_chromium . -# Install the OS packages, configure the environment, download the chromium source +gsutil cp -r gs://my-bucket/build_chromium . + +# Install the OS packages, configure the environment, download the chromium source (25GB) python ./build_chromium/init.sh [arch_name] # Run the build script with the path to the chromium src directory, the git commit id -python ./build_chromium/build.py +python ./build_chromium/build.py x86 -# You can add an architecture flag for ARM +# OR You can build for ARM python ./build_chromium/build.py arm64 ``` +**NOTE:** The `init.py` script updates git config to make it more possible for +the Chromium repo to be cloned successfully. If checking out the Chromium fails +with "early EOF" errors, the instance could be low on memory or disk space. + ## Getting the Commit ID -Getting `` can be tricky. The best technique seems to be: +The `build.py` script requires a commit ID of the Chromium repo. Getting `` can be tricky. The best technique seems to be: 1. Create a temporary working directory and intialize yarn 2. `yarn add puppeteer # install latest puppeter` -3. Look through puppeteer's node module files to find the "chromium revision" (a custom versioning convention for Chromium). +3. Look through Puppeteer documentation and Changelogs to find information +about where the "chromium revision" is located in the Puppeteer code. The code +containing it might not be distributed in the node module. + - Example: https://github.com/puppeteer/puppeteer/blob/b549256/src/revisions.ts 4. Use `https://crrev.com` and look up the revision and find the git commit info. - -The official Chromium build process is poorly documented, and seems to have -breaking changes fairly regularly. The build pre-requisites, and the build -flags change over time, so it is likely that the scripts in this directory will -be out of date by the time we have to do another Chromium build. - -This document is an attempt to note all of the gotchas we've come across while -building, so that the next time we have to tinker here, we'll have a good -starting point. + - Example: http://crrev.com/818858 leads to the git commit e62cb7e3fc7c40548cef66cdf19d270535d9350b ## Build args @@ -115,8 +130,8 @@ The more cores the better, as the build makes effective use of each. For Linux, - Linux: - SSH in using [gcloud](https://cloud.google.com/sdk/) - - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> gcloud - - Their in-browser UI is kinda sluggish, so use the commandline tool + - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> "View gcloud command" + - Their in-browser UI is kinda sluggish, so use the commandline tool (Google Cloud SDK is required) - Windows: - Install Microsoft's Remote Desktop tools diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 8622f4a9d4c0bb..0064f48ae973fe 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -33,10 +33,10 @@ base_version = source_version[:7].strip('.') # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'x64' +arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'unknown' if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') print('Building Chromium ' + source_version + ' for ' + arch_name + ' from ' + src_path) print('src path: ' + src_path) diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index c0dd60f1cfcb0c..3a2e28a884b096 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -8,18 +8,19 @@ # call this once the platform-specific initialization has completed. # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'x64' +arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'undefined' build_path = path.abspath(os.curdir) src_path = path.abspath(path.join(build_path, 'chromium', 'src')) if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') # Configure git print('Configuring git globals...') runcmd('git config --global core.autocrlf false') runcmd('git config --global core.filemode false') runcmd('git config --global branch.autosetuprebase always') +runcmd('git config --global core.compression 0') # Grab Chromium's custom build tools, if they aren't already installed # (On Windows, they are installed before this Python script is run) @@ -35,13 +36,14 @@ runcmd('git pull origin master') os.chdir(original_dir) -configure_environment(arch_name, build_path, src_path) - # Fetch the Chromium source code chromium_dir = path.join(build_path, 'chromium') if not path.isdir(chromium_dir): mkdir(chromium_dir) os.chdir(chromium_dir) - runcmd('fetch chromium') + runcmd('fetch chromium --nohooks=1 --no-history=1') else: print('Directory exists: ' + chromium_dir + '. Skipping chromium fetch.') + +# This depends on having the chromium/src directory with the complete checkout +configure_environment(arch_name, build_path, src_path) From 3720006cf8a5c264390a59e60d9403e0a3e9906f Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 29 Jan 2021 17:05:27 -0500 Subject: [PATCH 30/38] [CI] Move Jest tests to separate machines (#89770) --- vars/kibanaPipeline.groovy | 28 +++++++++++++++++++++------- vars/tasks.groovy | 5 +---- vars/workers.groovy | 2 ++ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 3e72c9e059af80..3032d88c26d98f 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -447,13 +447,27 @@ def withTasks(Map params = [worker: [:]], Closure closure) { } def allCiTasks() { - withTasks { - tasks.check() - tasks.lint() - tasks.test() - tasks.functionalOss() - tasks.functionalXpack() - } + parallel([ + general: { + withTasks { + tasks.check() + tasks.lint() + tasks.test() + tasks.functionalOss() + tasks.functionalXpack() + } + }, + jest: { + workers.ci(name: 'jest', size: 'c2-8', ramDisk: true) { + scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh')() + } + }, + xpackJest: { + workers.ci(name: 'xpack-jest', size: 'c2-8', ramDisk: true) { + scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh')() + } + }, + ]) } def pipelineLibraryTests() { diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 3493a95f0bdce5..6c4f897691136e 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -30,12 +30,9 @@ def lint() { def test() { tasks([ - // These 2 tasks require isolation because of hard-coded, conflicting ports and such, so let's use Docker here + // This task requires isolation because of hard-coded, conflicting ports and such, so let's use Docker here kibanaPipeline.scriptTaskDocker('Jest Integration Tests', 'test/scripts/test/jest_integration.sh'), - - kibanaPipeline.scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh'), kibanaPipeline.scriptTask('API Integration Tests', 'test/scripts/test/api_integration.sh'), - kibanaPipeline.scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh'), ]) } diff --git a/vars/workers.groovy b/vars/workers.groovy index dd634f3c25a326..e1684f7aadb434 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -19,6 +19,8 @@ def label(size) { return 'docker && tests-xl-highmem' case 'xxl': return 'docker && tests-xxl && gobld/machineType:custom-64-270336' + case 'c2-8': + return 'docker && linux && immutable && gobld/machineType:c2-standard-8' } error "unknown size '${size}'" From 2a913e4eb192b52bc12d3f66c1dd69f07205a08e Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 29 Jan 2021 15:53:29 -0700 Subject: [PATCH 31/38] Skips flake tests and tests with what looks like bugs (#89777) ## Summary Skips tests that have flake or in-determinism. * The sourcer code/tests are being rewritten and then those will come back by other team members. * The timeline open dialog looks to have some click and indeterminism bugs that are being investigated. Skipping for now. --- .../cypress/integration/data_sources/sourcerer.spec.ts | 4 +++- .../cypress/integration/timelines/creation.spec.ts | 5 +++-- x-pack/plugins/security_solution/cypress/tasks/timelines.ts | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts index 8b5871a6a67db0..857582aac76381 100644 --- a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts @@ -28,7 +28,9 @@ import { populateTimeline } from '../../tasks/timeline'; import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; -describe('Sourcerer', () => { +// Skipped at the moment as this has flake due to click handler issues. This has been raised with team members +// and the code is being re-worked and then these tests will be unskipped +describe.skip('Sourcerer', () => { before(() => { cleanKibana(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index 2bfd2fbf0054c2..ac70a1cae148e7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -47,7 +47,8 @@ import { openTimeline } from '../../tasks/timelines'; import { OVERVIEW_URL } from '../../urls/navigation'; -describe('Timelines', () => { +// Skipped at the moment as there looks to be in-deterministic bugs with the open timeline dialog. +describe.skip('Timelines', () => { beforeEach(() => { cleanKibana(); }); @@ -89,7 +90,7 @@ describe('Timelines', () => { cy.get(FAVORITE_TIMELINE).should('exist'); cy.get(TIMELINE_TITLE).should('have.text', timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); + cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `); cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); cy.get(PIN_EVENT) diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts index a04ecb1f9ccaa4..c2b5790b1ae123 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -19,7 +19,10 @@ export const exportTimeline = (timelineId: string) => { }; export const openTimeline = (id: string) => { - cy.get(TIMELINE(id), { timeout: 500 }).click(); + // This temporary wait here is to reduce flakeyness until we integrate cypress-pipe. Then please let us use cypress pipe. + // Ref: https://www.cypress.io/blog/2019/01/22/when-can-the-test-click/ + // Ref: https://github.com/NicholasBoll/cypress-pipe#readme + cy.get(TIMELINE(id)).should('be.visible').wait(1500).click(); }; export const waitForTimelinesPanelToBeLoaded = () => { From 2f80e44d3b2a1820b88b7b0c5a02922f768374ce Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 29 Jan 2021 19:16:19 -0700 Subject: [PATCH 32/38] [Security Solution][Detection Engine] Fixes indicator matches mapping UI where invalid list values can cause overwrites of other values (#89066) ## Summary This fixes the ReactJS keys to not use array indexes for the ReactJS keys which fixes https://github.com/elastic/kibana/issues/84893 as well as a few other bugs that I will show below. The fix for the ReactJS keys is to add a unique id version 4 `uuid.v4()` to the incoming threat_mapping and the entities. On save out to elastic I remove the id. This is considered [better practices for ReactJS keys](https://reactjs.org/docs/lists-and-keys.html) Down the road we might augment the arrays to have that id information but for now I add them when we get the data and then remove them as we save the data. This PR also: * Fixes tech debt around the hooks to remove the disabling of the `react-hooks/exhaustive-deps` in a few areas * Fixes one React Hook misnamed that would not have triggered React linter rules (_useRuleAsyn) * Adds 23 new Cypress e2e tests * Adds a new pattern of dealing with on button clicks for the Cypress tests that are make it less flakey ```ts cy.get(`button[title="${indexField}"]`) .should('be.visible') .then(([e]) => e.click()); ``` * Adds several new utilities to Cypress for testing rows for indicator matches and other Cypress utils to improve velocity and ergonomics ```ts fillIndicatorMatchRow getDefineContinueButton getIndicatorInvalidationText getIndicatorIndexComboField getIndicatorDeleteButton getIndicatorOrButton getIndicatorAndButton ``` ## Bug 1 Deleting row 1 can cause row 2 to be cleared out or only partial data to stick around. Before: ![im_bug_1](https://user-images.githubusercontent.com/1151048/105916137-c57b1d80-5fed-11eb-95b7-ad25b71cf4b8.gif) After: ![im_fix_1_1](https://user-images.githubusercontent.com/1151048/105917509-9fef1380-5fef-11eb-98eb-025c226f79fe.gif) ## Bug 2 Deleting row 2 in the middle of 3 rows did not shift the value up correctly Before: ![im_bug_2](https://user-images.githubusercontent.com/1151048/105917584-c01ed280-5fef-11eb-8c5b-fefb36f81008.gif) After: ![im_fix_2](https://user-images.githubusercontent.com/1151048/105917650-e0e72800-5fef-11eb-9fd3-020d52e4e3b1.gif) ## Bug 3 When using OR with values it does not shift up correctly similar to AND Before: ![im_bug_3](https://user-images.githubusercontent.com/1151048/105917691-f2303480-5fef-11eb-9368-b11d23159606.gif) After: ![im_fix_3](https://user-images.githubusercontent.com/1151048/105917714-f9574280-5fef-11eb-9be4-1f56c207525a.gif) ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../indicator_match_rule.spec.ts | 412 ++++++++++++++---- .../cypress/screens/create_new_rule.ts | 16 + .../cypress/tasks/create_new_rule.ts | 152 ++++++- .../threat_match/entry_item.test.tsx | 9 +- .../components/threat_match/entry_item.tsx | 12 +- .../components/threat_match/helpers.test.tsx | 15 +- .../components/threat_match/helpers.tsx | 33 +- .../common/components/threat_match/index.tsx | 76 ++-- .../threat_match/list_item.test.tsx | 9 - .../components/threat_match/list_item.tsx | 4 +- .../components/threat_match/reducer.test.ts | 8 + .../common/components/threat_match/types.ts | 1 + .../utils/add_remove_id_to_item.test.ts | 76 ++++ .../common/utils/add_remove_id_to_item.ts | 49 +++ .../alerts/use_privilege_user.tsx | 7 +- .../detection_engine/alerts/use_query.tsx | 4 +- .../alerts/use_signal_index.tsx | 3 +- .../detection_engine/rules/transforms.ts | 98 +++++ .../rules/use_create_rule.tsx | 10 +- .../rules/use_pre_packaged_rules.tsx | 10 +- .../detection_engine/rules/use_rule.tsx | 18 +- .../detection_engine/rules/use_rule_async.tsx | 12 +- .../rules/use_rule_status.tsx | 6 +- .../detection_engine/rules/use_tags.tsx | 7 +- .../rules/use_update_rule.tsx | 11 +- 25 files changed, 857 insertions(+), 201 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 37123dedfd6613..2c9dc14aa05b23 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -5,7 +5,7 @@ */ import { formatMitreAttackDescription } from '../../helpers/rules'; -import { newThreatIndicatorRule } from '../../objects/rule'; +import { indexPatterns, newThreatIndicatorRule } from '../../objects/rule'; import { ALERT_RULE_METHOD, @@ -70,7 +70,24 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineIndicatorMatchRuleAndContinue, + fillIndexAndIndicatorIndexPattern, + fillIndicatorMatchRow, fillScheduleRuleAndContinue, + getCustomIndicatorQueryInput, + getCustomQueryInput, + getCustomQueryInvalidationText, + getDefineContinueButton, + getIndexPatternClearButton, + getIndexPatternInvalidationText, + getIndicatorAndButton, + getIndicatorAtLeastOneInvalidationText, + getIndicatorDeleteButton, + getIndicatorIndex, + getIndicatorIndexComboField, + getIndicatorIndicatorIndex, + getIndicatorInvalidationText, + getIndicatorMappingComboField, + getIndicatorOrButton, selectIndicatorMatchType, waitForAlertsToPopulate, waitForTheRuleToBeExecuted, @@ -92,14 +109,6 @@ describe('Detection rules, Indicator Match', () => { cleanKibana(); esArchiverLoad('threat_indicator'); esArchiverLoad('threat_data'); - }); - - afterEach(() => { - esArchiverUnload('threat_indicator'); - esArchiverUnload('threat_data'); - }); - - it('Creates and activates a new Indicator Match rule', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); @@ -107,89 +116,330 @@ describe('Detection rules, Indicator Match', () => { waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); goToCreateNewRule(); selectIndicatorMatchType(); - fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); - fillAboutRuleAndContinue(newThreatIndicatorRule); - fillScheduleRuleAndContinue(newThreatIndicatorRule); - createAndActivateRule(); + }); + + afterEach(() => { + esArchiverUnload('threat_indicator'); + esArchiverUnload('threat_data'); + }); - cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + describe('Creating new indicator match rules', () => { + describe('Index patterns', () => { + it('Contains a predefined index pattern', () => { + getIndicatorIndex().should('have.text', indexPatterns.join('')); + }); - changeToThreeHundredRowsPerPage(); - waitForRulesToBeLoaded(); + it('Does NOT show invalidation text on initial page load if indicator index pattern is filled out', () => { + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + it('Shows invalidation text when you try to continue without filling it out', () => { + getIndexPatternClearButton().click(); + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - filterByCustomRules(); + describe('Indicator index patterns', () => { + it('Contains empty index pattern', () => { + getIndicatorIndicatorIndex().should('have.text', ''); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + it('Shows invalidation text if you try to continue without filling it out', () => { + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); - cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); - cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - - goToRuleDetails(); - - cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); - cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); - cy.get(ABOUT_DETAILS).within(() => { - getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); - getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should((details) => { - expect(removeExternalLinkText(details.text())).equal(expectedUrls); - }); - getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { - expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); - }); - getDetails(TAGS_DETAILS).should('have.text', expectedTags); + + describe('custom query input', () => { + it('Has a default set of *:*', () => { + getCustomQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); - cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); - - cy.get(DEFINITION_DETAILS).within(() => { - getDetails(INDEX_PATTERNS_DETAILS).should( - 'have.text', - newThreatIndicatorRule.index!.join('') - ); - getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); - getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); - getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); - getDetails(INDICATOR_INDEX_PATTERNS).should( - 'have.text', - newThreatIndicatorRule.indicatorIndexPattern.join('') - ); - getDetails(INDICATOR_MAPPING).should( - 'have.text', - `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` - ); - getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + + describe('custom indicator query input', () => { + it('Has a default set of *:*', () => { + getCustomIndicatorQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomIndicatorQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(SCHEDULE_DETAILS).within(() => { - getDetails(RUNS_EVERY_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` - ); - getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` - ); + describe('Indicator mapping', () => { + beforeEach(() => { + fillIndexAndIndicatorIndexPattern( + newThreatIndicatorRule.index, + newThreatIndicatorRule.indicatorIndexPattern + ); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when you try to press continue without filling anything out', () => { + getDefineContinueButton().click(); + getIndicatorAtLeastOneInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "AND" button is pressed and both the mappings are blank', () => { + getIndicatorAndButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "OR" button is pressed and both the mappings are blank', () => { + getIndicatorOrButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when there is an invalid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'agent.name', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'agent.name'); + getIndicatorMappingComboField().should( + 'have.text', + newThreatIndicatorRule.indicatorIndexField + ); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'second-non-existent-value', + validColumns: 'indexField', + }); + getIndicatorDeleteButton().click(); + getIndicatorMappingComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "indicator index fields" and invalid "index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'second-non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', 'Search'); + getIndicatorMappingComboField().should('text', 'Search'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'non-existent-value', + indicatorIndexField: 'non-existent-value', + validColumns: 'none', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 3, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton(2).click(); + getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(1).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(2).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(3).should('not.exist'); + getIndicatorMappingComboField(3).should('not.exist'); + }); + + it('Can add two OR rows and delete the second row. The first row has invalid data and the second row has valid data. The first row is deleted and the second row shifts up correctly.', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value-one', + indicatorIndexField: 'non-existent-value-two', + validColumns: 'none', + }); + getIndicatorOrButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField().should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); }); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); - cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); - cy.get(ALERT_RULE_SEVERITY) - .first() - .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); - cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + it('Creates and activates a new Indicator Match rule', () => { + fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); + fillAboutRuleAndContinue(newThreatIndicatorRule); + fillScheduleRuleAndContinue(newThreatIndicatorRule); + createAndActivateRule(); + + cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + + filterByCustomRules(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + }); + cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); + cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); + cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); + + goToRuleDetails(); + + cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); + cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); + cy.get(ABOUT_DETAILS).within(() => { + getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); + getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); + getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); + getDetails(TAGS_DETAILS).should('have.text', expectedTags); + }); + cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); + cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(INDEX_PATTERNS_DETAILS).should( + 'have.text', + newThreatIndicatorRule.index!.join('') + ); + getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); + getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); + getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); + getDetails(INDICATOR_INDEX_PATTERNS).should( + 'have.text', + newThreatIndicatorRule.indicatorIndexPattern.join('') + ); + getDetails(INDICATOR_MAPPING).should( + 'have.text', + `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` + ); + getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + }); + + cy.get(SCHEDULE_DETAILS).within(() => { + getDetails(RUNS_EVERY_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` + ); + getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` + ); + }); + + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); + cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); + cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); + cy.get(ALERT_RULE_SEVERITY) + .first() + .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); + cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 66681e77b7eb9e..2a59dd33399c57 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -38,6 +38,22 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const THREAT_MATCH_QUERY_INPUT = '[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]'; +export const THREAT_MATCH_AND_BUTTON = '[data-test-subj="andButton"]'; + +export const THREAT_ITEM_ENTRY_DELETE_BUTTON = '[data-test-subj="itemEntryDeleteButton"]'; + +export const THREAT_MATCH_OR_BUTTON = '[data-test-subj="orButton"]'; + +export const THREAT_COMBO_BOX_INPUT = '[data-test-subj="fieldAutocompleteComboBox"]'; + +export const INVALID_MATCH_CONTENT = 'All matches require both a field and threat index field.'; + +export const AT_LEAST_ONE_VALID_MATCH = 'At least one indicator match is required.'; + +export const AT_LEAST_ONE_INDEX_PATTERN = 'A minimum of one index pattern is required.'; + +export const CUSTOM_QUERY_REQUIRED = 'A custom query is required.'; + export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 7836960b1a6941..5143dc27e7d7ad 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -63,13 +63,20 @@ import { EQL_QUERY_PREVIEW_HISTOGRAM, EQL_QUERY_VALIDATION_SPINNER, COMBO_BOX_CLEAR_BTN, - COMBO_BOX_RESULT, MITRE_ATTACK_TACTIC_DROPDOWN, MITRE_ATTACK_TECHNIQUE_DROPDOWN, MITRE_ATTACK_SUBTECHNIQUE_DROPDOWN, MITRE_ATTACK_ADD_TACTIC_BUTTON, MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON, MITRE_ATTACK_ADD_TECHNIQUE_BUTTON, + THREAT_COMBO_BOX_INPUT, + THREAT_ITEM_ENTRY_DELETE_BUTTON, + THREAT_MATCH_AND_BUTTON, + INVALID_MATCH_CONTENT, + THREAT_MATCH_OR_BUTTON, + AT_LEAST_ONE_VALID_MATCH, + AT_LEAST_ONE_INDEX_PATTERN, + CUSTOM_QUERY_REQUIRED, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -144,7 +151,7 @@ export const fillAboutRuleAndContinue = ( rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule ) => { fillAboutRule(rule); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { @@ -222,7 +229,7 @@ export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { cy.get(COMBO_BOX_INPUT).type(`${rule.timestampOverride}{enter}`); }); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillDefineCustomRuleWithImportedQueryAndContinue = ( @@ -282,19 +289,132 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { cy.get(EQL_QUERY_INPUT).should('not.exist'); }; +/** + * Fills in the indicator match rows for tests by giving it an optional rowNumber, + * a indexField, a indicatorIndexField, and an optional validRows which indicates + * which row is valid or not. + * + * There are special tricks below with Eui combo box: + * cy.get(`button[title="${indexField}"]`) + * .should('be.visible') + * .then(([e]) => e.click()); + * + * To first ensure the button is there before clicking on the button. There are + * race conditions where if the Eui drop down button from the combo box is not + * visible then the click handler is not there either, and when we click on it + * that will cause the item to _not_ be selected. Using a {enter} with the combo + * box also does not select things from EuiCombo boxes either, so I have to click + * the actual contents of the EuiCombo box to select things. + */ +export const fillIndicatorMatchRow = ({ + rowNumber, + indexField, + indicatorIndexField, + validColumns, +}: { + rowNumber?: number; // default is 1 + indexField: string; + indicatorIndexField: string; + validColumns?: 'indexField' | 'indicatorField' | 'both' | 'none'; // default is both are valid entries +}) => { + const computedRowNumber = rowNumber == null ? 1 : rowNumber; + const computedValueRows = validColumns == null ? 'both' : validColumns; + const OFFSET = 2; + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 1) + .type(indexField); + if (computedValueRows === 'indexField' || computedValueRows === 'both') { + cy.get(`button[title="${indexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 2) + .type(indicatorIndexField); + + if (computedValueRows === 'indicatorField' || computedValueRows === 'both') { + cy.get(`button[title="${indicatorIndexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } +}; + +/** + * Fills in both the index pattern and the indicator match index pattern. + * @param indexPattern The index pattern. + * @param indicatorIndex The indicator index pattern. + */ +export const fillIndexAndIndicatorIndexPattern = ( + indexPattern?: string[], + indicatorIndex?: string[] +) => { + getIndexPatternClearButton().click(); + getIndicatorIndex().type(`${indexPattern}{enter}`); + getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`); +}; + +/** Returns the indicator index drop down field. Pass in row number, default is 1 */ +export const getIndicatorIndexComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2); + +/** Returns the indicator mapping drop down field. Pass in row number, default is 1 */ +export const getIndicatorMappingComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 1); + +/** Returns the indicator matches DELETE button for the mapping. Pass in row number, default is 1 */ +export const getIndicatorDeleteButton = (row = 1) => + cy.get(THREAT_ITEM_ENTRY_DELETE_BUTTON).eq(row - 1); + +/** Returns the indicator matches AND button for the mapping */ +export const getIndicatorAndButton = () => cy.get(THREAT_MATCH_AND_BUTTON); + +/** Returns the indicator matches OR button for the mapping */ +export const getIndicatorOrButton = () => cy.get(THREAT_MATCH_OR_BUTTON); + +/** Returns the invalid match content. */ +export const getIndicatorInvalidationText = () => cy.contains(INVALID_MATCH_CONTENT); + +/** Returns that at least one valid match is required content */ +export const getIndicatorAtLeastOneInvalidationText = () => cy.contains(AT_LEAST_ONE_VALID_MATCH); + +/** Returns that at least one index pattern is required content */ +export const getIndexPatternInvalidationText = () => cy.contains(AT_LEAST_ONE_INDEX_PATTERN); + +/** Returns the continue button on the step of about */ +export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN); + +/** Returns the continue button on the step of define */ +export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON); + +/** Returns the indicator index pattern */ +export const getIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(0); + +/** Returns the indicator's indicator index */ +export const getIndicatorIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(2); + +/** Returns the index pattern's clear button */ +export const getIndexPatternClearButton = () => cy.get(COMBO_BOX_CLEAR_BTN); + +/** Returns the custom query input */ +export const getCustomQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(0); + +/** Returns the custom query input */ +export const getCustomIndicatorQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(1); + +/** Returns custom query required content */ +export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQUIRED); + +/** + * Fills in the define indicator match rules and then presses the continue button + * @param rule The rule to use to fill in everything + */ export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => { - const INDEX_PATTERNS = 0; - const INDICATOR_INDEX_PATTERN = 2; - const INDICATOR_MAPPING = 3; - const INDICATOR_INDEX_FIELD = 4; - - cy.get(COMBO_BOX_CLEAR_BTN).click(); - cy.get(COMBO_BOX_INPUT).eq(INDEX_PATTERNS).type(`${rule.index}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_PATTERN).type(`${rule.indicatorIndexPattern}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_MAPPING).type(`${rule.indicatorMapping}{enter}`); - cy.get(COMBO_BOX_RESULT).first().click(); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_FIELD).type(`${rule.indicatorIndexField}{enter}`); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern); + fillIndicatorMatchRow({ + indexField: rule.indicatorMapping, + indicatorIndexField: rule.indicatorIndexField, + }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; @@ -304,7 +424,7 @@ export const fillDefineMachineLearningRuleAndContinue = (rule: MachineLearningRu cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${machineLearningRule.anomalyScoreThreshold}`, { force: true, }); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(MACHINE_LEARNING_DROPDOWN).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx index 36033c358766df..ce6ca7ebc22ddf 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx @@ -22,6 +22,7 @@ describe('EntryItem', () => { const wrapper = mount( { const wrapper = mount( { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'machine.os', type: 'mapping', value: 'ip', @@ -97,6 +100,7 @@ describe('EntryItem', () => { const wrapper = mount( { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange([{ label: 'is not' }]); - expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0); + expect(mockOnChange).toHaveBeenCalledWith( + { id: '123', field: 'ip', type: 'mapping', value: '' }, + 0 + ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx index c99e63ff4eda08..51b724bff2e5d0 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx @@ -75,7 +75,11 @@ export const EntryItem: React.FC = ({
    ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleFieldChange, indexPattern, entry, showLabel]); @@ -101,7 +105,11 @@ export const EntryItem: React.FC = ({
    ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx index b4f97808b54c41..b3a74c76977152 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx @@ -21,6 +21,10 @@ import { } from './helpers'; import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const getMockIndexPattern = (): IndexPattern => ({ id: '1234', @@ -29,6 +33,7 @@ const getMockIndexPattern = (): IndexPattern => } as IndexPattern); const getMockEntry = (): FormattedEntry => ({ + id: '123', field: getField('ip'), value: getField('ip'), type: 'mapping', @@ -42,6 +47,7 @@ describe('Helpers', () => { afterEach(() => { moment.tz.setDefault('Browser'); + jest.clearAllMocks(); }); describe('#getFormattedEntry', () => { @@ -70,6 +76,7 @@ describe('Helpers', () => { const output = getFormattedEntry(payloadIndexPattern, payloadIndexPattern, payloadItem, 0); const expected: FormattedEntry = { entryIndex: 0, + id: '123', field: { name: 'machine.os.raw.text', type: 'string', @@ -94,6 +101,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, value: undefined, @@ -109,6 +117,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -134,6 +143,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, threatIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -170,6 +180,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', field: { name: 'machine.os', type: 'string', @@ -194,6 +205,7 @@ describe('Helpers', () => { entryIndex: 0, }, { + id: '123', field: { name: 'ip', type: 'ip', @@ -249,9 +261,10 @@ describe('Helpers', () => { const payloadItem = getMockEntry(); const payloadIFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: Entry; index: number } = { + const expected: { updatedEntry: Entry & { id: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', type: 'mapping', value: 'ip', diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index 349dae76301d49..90a996c06e4924 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { ThreatMap, threatMap, @@ -12,6 +13,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; +import { addIdToItem } from '../../utils/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. @@ -24,7 +26,8 @@ export const getFormattedEntry = ( indexPattern: IndexPattern, threatIndexPatterns: IndexPattern, item: Entry, - itemIndex: number + itemIndex: number, + uuidGen: () => string = uuid.v4 ): FormattedEntry => { const { fields } = indexPattern; const { fields: threatFields } = threatIndexPatterns; @@ -34,7 +37,9 @@ export const getFormattedEntry = ( const [threatFoundField] = threatFields.filter( ({ name }) => threatField != null && threatField === name ); + const maybeId: typeof item & { id?: string } = item; return { + id: maybeId.id ?? uuidGen(), field: foundField, type: 'mapping', value: threatFoundField, @@ -90,10 +95,11 @@ export const getEntryOnFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: 'mapping', value: item.value != null ? item.value.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; @@ -112,30 +118,33 @@ export const getEntryOnThreatFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: item.field != null ? item.field.name : '', type: 'mapping', value: newField != null ? newField.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; -export const getDefaultEmptyEntry = (): EmptyEntry => ({ - field: '', - type: 'mapping', - value: '', -}); +export const getDefaultEmptyEntry = (): EmptyEntry => { + return addIdToItem({ + field: '', + type: 'mapping', + value: '', + }); +}; export const getNewItem = (): ThreatMap => { - return { + return addIdToItem({ entries: [ - { + addIdToItem({ field: '', type: 'mapping', value: '', - }, + }), ], - }; + }); }; export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx index d3936e10bd877a..8aa4af21b03ccb 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx @@ -158,43 +158,45 @@ export const ThreatMatchComponent = ({ }, []); return ( - {entries.map((entryListItem, index) => ( - - - {index !== 0 && - (andLogicIncluded ? ( - - - - - - - - - - - ) : ( - - - - ))} - - - - - - ))} + {entries.map((entryListItem, index) => { + const key = (entryListItem as typeof entryListItem & { id?: string }).id ?? `${index}`; + return ( + + + {index !== 0 && + (andLogicIncluded ? ( + + + + + + + + + + + ) : ( + + + + ))} + + + + + + ); + })} diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx index 90492bc46e2b0b..66af24025656e6 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx @@ -68,7 +68,6 @@ describe('ListItemComponent', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( ( ({ listItem, - listId, listItemIndex, indexPattern, threatIndexPatterns, @@ -88,7 +86,7 @@ export const ListItemComponent = React.memo( {entries.map((item, index) => ( - + ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { andLogicIncluded: false, entries: [], @@ -22,6 +26,10 @@ const getEntry = (): ThreatMapEntry => ({ }); describe('reducer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('#setEntries', () => { test('should return "andLogicIncluded" ', () => { const update = reducer()(initialState, { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts index 0cbd885db2d546..f3af5faaed25c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts @@ -7,6 +7,7 @@ import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/s import { IFieldType } from '../../../../../../../src/plugins/data/common'; export interface FormattedEntry { + id: string; field: IFieldType | undefined; type: 'mapping'; value: IFieldType | undefined; diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts new file mode 100644 index 00000000000000..fa067a53f25731 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { addIdToItem, removeIdFromItem } from './add_remove_id_to_item'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('add_remove_id_to_item', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addIdToItem', () => { + test('it adds an id to an empty item', () => { + expect(addIdToItem({})).toEqual({ id: '123' }); + }); + + test('it adds a complex object', () => { + expect( + addIdToItem({ + field: '', + type: 'mapping', + value: '', + }) + ).toEqual({ + id: '123', + field: '', + type: 'mapping', + value: '', + }); + }); + + test('it adds an id to an existing item', () => { + expect(addIdToItem({ test: '456' })).toEqual({ id: '123', test: '456' }); + }); + + test('it does not change the id if it already exists', () => { + expect(addIdToItem({ id: '456' })).toEqual({ id: '456' }); + }); + + test('it returns the same reference if it has an id already', () => { + const obj = { id: '456' }; + expect(addIdToItem(obj)).toBe(obj); + }); + + test('it returns a new reference if it adds an id to an item', () => { + const obj = { test: '456' }; + expect(addIdToItem(obj)).not.toBe(obj); + }); + }); + + describe('removeIdFromItem', () => { + test('it removes an id from an item', () => { + expect(removeIdFromItem({ id: '456' })).toEqual({}); + }); + + test('it returns a new reference if it removes an id from an item', () => { + const obj = { id: '123', test: '456' }; + expect(removeIdFromItem(obj)).not.toBe(obj); + }); + + test('it does not effect an item without an id', () => { + expect(removeIdFromItem({ test: '456' })).toEqual({ test: '456' }); + }); + + test('it returns the same reference if it does not have an id already', () => { + const obj = { test: '456' }; + expect(removeIdFromItem(obj)).toBe(obj); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts new file mode 100644 index 00000000000000..a74cf8680fa485 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts @@ -0,0 +1,49 @@ +/* + * 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 uuid from 'uuid'; + +/** + * This is useful for when you have arrays without an ID and need to add one for + * ReactJS keys. I break the types slightly by introducing an id to an arbitrary item + * but then cast it back to the regular type T. + * Usage of this could be considered tech debt as I am adding an ID when the backend + * could be doing the same thing but it depends on how you want to model your data and + * if you view modeling your data with id's to please ReactJS a good or bad thing. + * @param item The item to add an id to. + */ +type NotArray = T extends unknown[] ? never : T; +export const addIdToItem = (item: NotArray): T => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + return item; + } else { + return { ...item, id: uuid.v4() }; + } +}; + +/** + * This is to reverse the id you added to your arrays for ReactJS keys. + * @param item The item to remove the id from. + */ +export const removeIdFromItem = ( + item: NotArray +): + | T + | Pick< + T & { + id?: string | undefined; + }, + Exclude + > => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + const { id, ...noId } = maybeId; + return noId; + } else { + return item; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx index b72dd3b2f84dd7..191c3955caa9ba 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx @@ -50,7 +50,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { const abortCtrl = new AbortController(); setLoading(true); - async function fetchData() { + const fetchData = async () => { try { const privilege = await getUserPrivilege({ signal: abortCtrl.signal, @@ -89,15 +89,14 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...privilegeUser }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 3bef1d8edd048e..9022e3a32163c4 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -46,7 +46,7 @@ export const useQueryAlerts = ( let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { try { setLoading(true); const alertResponse = await fetchQueryAlerts({ @@ -77,7 +77,7 @@ export const useQueryAlerts = ( if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx index 5ebdb38b8dd5c5..bfdc1d1ceee215 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx @@ -106,8 +106,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...signalIndex }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts new file mode 100644 index 00000000000000..7821bb23a7ca38 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts @@ -0,0 +1,98 @@ +/* + * 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 { flow } from 'fp-ts/lib/function'; +import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item'; +import { + CreateRulesSchema, + UpdateRulesSchema, +} from '../../../../../common/detection_engine/schemas/request'; +import { Rule } from './types'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformOutput = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => flow(removeIdFromThreatMatchArray)(rule); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformInput = (rule: Rule): Rule => flow(addIdToThreatMatchArray)(rule); + +/** + * This adds an id to the incoming threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same rule as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case just cast this temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param rule The rule to add an id to the threat matches. + * @returns rule The rule but with id added to the threat array and entries + */ +export const addIdToThreatMatchArray = (rule: Rule): Rule => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => addIdToItem(entry)); + return addIdToItem({ entries: newEntries }); + }); + return { ...rule, threat_mapping: threatMapWithId }; + } else { + return rule; + } +}; + +/** + * This removes an id from the threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param rule The rule to remove an id from the threat matches. + * @returns rule The rule but with id removed from the threat array and entries + */ +export const removeIdFromThreatMatchArray = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithoutId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => removeIdFromItem(entry)); + const newMapping = removeIdFromItem(mapping); + return { ...newMapping, entries: newEntries }; + }); + return { ...rule, threat_mapping: threatMapWithoutId }; + } else { + return rule; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx index 2bbd27994fc771..fe8e0fd8ceb970 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx @@ -11,6 +11,7 @@ import { CreateRulesSchema } from '../../../../../common/detection_engine/schema import { createRule } from './api'; import * as i18n from './translations'; +import { transformOutput } from './transforms'; interface CreateRuleReturn { isLoading: boolean; @@ -29,11 +30,11 @@ export const useCreateRule = (): ReturnCreateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await createRule({ rule, signal: abortCtrl.signal }); + await createRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +47,14 @@ export const useCreateRule = (): ReturnCreateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx index d83d4e0caa9771..bdbe13af401517 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -262,8 +262,14 @@ export const usePrePackagedRules = ({ isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canUserCRUD, hasIndexWrite, isAuthenticated, hasEncryptionKey, isSignalIndexExists]); + }, [ + canUserCRUD, + hasIndexWrite, + isAuthenticated, + hasEncryptionKey, + isSignalIndexExists, + dispatchToaster, + ]); const prePackagedRuleStatus = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx index 706c2645a4dddc..3b84558d344e7f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { fetchRuleById } from './api'; +import { transformInput } from './transforms'; import * as i18n from './translations'; import { Rule } from './types'; @@ -28,13 +29,15 @@ export const useRule = (id: string | undefined): ReturnRule => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData(idToFetch: string) { + const fetchData = async (idToFetch: string) => { try { setLoading(true); - const ruleResponse = await fetchRuleById({ - id: idToFetch, - signal: abortCtrl.signal, - }); + const ruleResponse = transformInput( + await fetchRuleById({ + id: idToFetch, + signal: abortCtrl.signal, + }) + ); if (isSubscribed) { setRule(ruleResponse); } @@ -47,7 +50,7 @@ export const useRule = (id: string | undefined): ReturnRule => { if (isSubscribed) { setLoading(false); } - } + }; if (id != null) { fetchData(id); } @@ -55,8 +58,7 @@ export const useRule = (id: string | undefined): ReturnRule => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, rule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx index fbca46097dcd9c..48bfe71b4722bf 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx @@ -6,12 +6,14 @@ import { useEffect, useCallback } from 'react'; +import { flow } from 'fp-ts/lib/function'; import { useAsync, withOptionalSignal } from '../../../../shared_imports'; import { useHttp } from '../../../../common/lib/kibana'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { pureFetchRuleById } from './api'; import { Rule } from './types'; import * as i18n from './translations'; +import { transformInput } from './transforms'; export interface UseRuleAsync { error: unknown; @@ -20,11 +22,15 @@ export interface UseRuleAsync { rule: Rule | null; } -const _fetchRule = withOptionalSignal(pureFetchRuleById); -const _useRuleAsync = () => useAsync(_fetchRule); +const _fetchRule = flow(withOptionalSignal(pureFetchRuleById), async (rule: Promise) => + transformInput(await rule) +); + +/** This does not use "_useRuleAsyncInternal" as that would deactivate the useHooks linter rule, so instead it has the word "Internal" post-pended */ +const useRuleAsyncInternal = () => useAsync(_fetchRule); export const useRuleAsync = (ruleId: string): UseRuleAsync => { - const { start, loading, result, error } = _useRuleAsync(); + const { start, loading, result, error } = useRuleAsyncInternal(); const http = useHttp(); const { addError } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index ddf50e9edae518..2bec8f9a2d0a24 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -64,8 +64,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, ruleStatus, fetchRuleStatus.current]; }; @@ -122,8 +121,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rules]); + }, [rules, dispatchToaster]); return { loading, rulesStatuses }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx index 038f974e1394ec..bab419813e1aa0 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx @@ -26,7 +26,7 @@ export const useTags = (): ReturnTags => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { setLoading(true); try { const fetchTagsResult = await fetchTags({ @@ -44,7 +44,7 @@ export const useTags = (): ReturnTags => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); reFetchTags.current = fetchData; @@ -53,8 +53,7 @@ export const useTags = (): ReturnTags => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return [loading, tags, reFetchTags.current]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx index a437974e93ba30..729336b697e4d3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx @@ -9,6 +9,8 @@ import { useEffect, useState, Dispatch } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; +import { transformOutput } from './transforms'; + import { updateRule } from './api'; import * as i18n from './translations'; @@ -29,11 +31,11 @@ export const useUpdateRule = (): ReturnUpdateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await updateRule({ rule, signal: abortCtrl.signal }); + await updateRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +48,14 @@ export const useUpdateRule = (): ReturnUpdateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; From 05b7107ff2274987b4c37889813cd4e685eca184 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Sat, 30 Jan 2021 10:49:59 +0100 Subject: [PATCH 33/38] Add APM API tests dir to CODEOWNERS (#89573) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3343544d57fad2..9e31bd31b4037e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -66,6 +66,7 @@ # APM /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui +/x-pack/test/apm_api_integration/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @elastic/kibana-core @vigneshshanmugam /packages/kbn-apm-config-loader/ @elastic/kibana-core @vigneshshanmugam @@ -80,6 +81,7 @@ /x-pack/plugins/apm/server/lib/rum_client @elastic/uptime /x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime /x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @elastic/uptime +/x-pack/test/apm_api_integration/tests/csm/ @elastic/uptime # Beats /x-pack/plugins/beats_management/ @elastic/beats From 52f54030c356447f6896e603b60350be97389fd2 Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Sat, 30 Jan 2021 08:25:45 -0500 Subject: [PATCH 34/38] [Security Solution] [Detections] rename gap column and delete "last lookback date" column from monitoring table (#89801) --- .../detection_engine/rules/all/columns.tsx | 27 ++++++++++--------- .../detection_engine/rules/translations.ts | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 0d585b44638153..86f24594fc57ed 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -356,19 +356,20 @@ export const getMonitoringColumns = ( truncateText: true, width: '14%', }, - { - field: 'current_status.last_look_back_date', - name: i18n.COLUMN_LAST_LOOKBACK_DATE, - render: (value: RuleStatus['current_status']['last_look_back_date']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); - }, - truncateText: true, - width: '16%', - }, + // hiding this field until after 7.11 release + // { + // field: 'current_status.last_look_back_date', + // name: i18n.COLUMN_LAST_LOOKBACK_DATE, + // render: (value: RuleStatus['current_status']['last_look_back_date']) => { + // return value == null ? ( + // getEmptyTagValue() + // ) : ( + // + // ); + // }, + // truncateText: true, + // width: '16%', + // }, { field: 'current_status.status_date', name: i18n.COLUMN_LAST_COMPLETE_RUN, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 2d993c7be08b01..f7066cd42e4c17 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -353,7 +353,7 @@ export const COLUMN_QUERY_TIMES = i18n.translate( export const COLUMN_GAP = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.gap', { - defaultMessage: 'Gap (if any)', + defaultMessage: 'Last Gap (if any)', } ); From 841ab704b8e50986730a32e68f9afc3ac28b92cd Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Sun, 31 Jan 2021 12:16:46 +0200 Subject: [PATCH 35/38] [Search Sessions] Improve search session errors (#88613) * Detect ESError correctly Fix bfetch error (was recognized as unknown error) Make sure handleSearchError always returns an error object. * fix tests and improve types * type * normalize search error response format for search and bsearch * type * Added es search exception examples * Normalize and validate errors thrown from oss es_search_strategy Validate abort * Added tests for search service error handling * Update msearch tests to test for errors * Moved bsearch route to routes folder Adjusted bsearch response format Added verification of error's root cause * Align painless error object * eslint * Add to seach interceptor tests * add json to tsconfig * docs * updated xpack search strategy tests * oops * license header * Add test for xpack painless error format * doc * Fix bsearch test potential flakiness * code review * fix * code review 2 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...lic.searchinterceptor.handlesearcherror.md | 4 +- ...public.searchtimeouterror._constructor_.md | 4 +- .../test_data/illegal_argument_exception.json | 14 ++ .../test_data/index_not_found_exception.json | 21 ++ .../test_data/json_e_o_f_exception.json | 14 ++ .../search/test_data/parsing_exception.json | 17 ++ .../resource_not_found_exception.json | 13 + .../search_phase_execution_exception.json | 52 ++++ .../test_data/x_content_parse_exception.json | 17 ++ src/plugins/data/public/public.api.md | 7 +- .../public/search/errors/es_error.test.tsx | 19 +- .../data/public/search/errors/es_error.tsx | 8 +- .../search/errors/painless_error.test.tsx | 42 ++++ .../public/search/errors/painless_error.tsx | 10 +- .../public/search/errors/timeout_error.tsx | 2 +- .../data/public/search/errors/types.ts | 72 +++--- .../data/public/search/errors/utils.ts | 16 +- .../public/search/search_interceptor.test.ts | 74 +++--- .../data/public/search/search_interceptor.ts | 23 +- .../es_search/es_search_strategy.test.ts | 161 ++++++++++-- .../search/es_search/es_search_strategy.ts | 31 ++- .../data/server/search/routes/bsearch.ts | 65 +++++ .../data/server/search/routes/call_msearch.ts | 36 +-- .../data/server/search/routes/msearch.test.ts | 58 ++++- .../data/server/search/routes/search.test.ts | 99 ++++++-- .../data/server/search/search_service.ts | 55 +---- src/plugins/data/tsconfig.json | 2 +- .../kibana_utils/common/errors/index.ts | 1 + .../kibana_utils/common/errors/types.ts | 12 + src/plugins/kibana_utils/server/index.ts | 2 +- .../server/report_server_error.ts | 29 ++- test/api_integration/apis/search/bsearch.ts | 172 +++++++++++++ test/api_integration/apis/search/index.ts | 1 + .../apis/search/painless_err_req.ts | 44 ++++ test/api_integration/apis/search/search.ts | 81 ++++++- .../apis/search/verify_error.ts | 27 +++ .../search_phase_execution_exception.json | 229 ++++++++++++++++++ .../public/search/search_interceptor.test.ts | 41 +++- .../server/search/es_search_strategy.test.ts | 101 ++++++++ .../server/search/es_search_strategy.ts | 79 ++++-- x-pack/plugins/data_enhanced/tsconfig.json | 3 +- .../api_integration/apis/search/search.ts | 36 ++- 42 files changed, 1499 insertions(+), 295 deletions(-) create mode 100644 src/plugins/data/common/search/test_data/illegal_argument_exception.json create mode 100644 src/plugins/data/common/search/test_data/index_not_found_exception.json create mode 100644 src/plugins/data/common/search/test_data/json_e_o_f_exception.json create mode 100644 src/plugins/data/common/search/test_data/parsing_exception.json create mode 100644 src/plugins/data/common/search/test_data/resource_not_found_exception.json create mode 100644 src/plugins/data/common/search/test_data/search_phase_execution_exception.json create mode 100644 src/plugins/data/common/search/test_data/x_content_parse_exception.json create mode 100644 src/plugins/data/public/search/errors/painless_error.test.tsx create mode 100644 src/plugins/data/server/search/routes/bsearch.ts create mode 100644 src/plugins/kibana_utils/common/errors/types.ts create mode 100644 test/api_integration/apis/search/bsearch.ts create mode 100644 test/api_integration/apis/search/painless_err_req.ts create mode 100644 test/api_integration/apis/search/verify_error.ts create mode 100644 x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md index b5ac4a4e53887e..5f8966f0227ac5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md @@ -7,14 +7,14 @@ Signature: ```typescript -protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; +protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| e | any | | +| e | KibanaServerError | AbortError | | | timeoutSignal | AbortSignal | | | options | ISearchOptions | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md index 1c6370c7d03561..b4eecca665e827 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md @@ -9,13 +9,13 @@ Constructs a new instance of the `SearchTimeoutError` class Signature: ```typescript -constructor(err: Error, mode: TimeoutErrorMode); +constructor(err: Record, mode: TimeoutErrorMode); ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| err | Error | | +| err | Record<string, any> | | | mode | TimeoutErrorMode | | diff --git a/src/plugins/data/common/search/test_data/illegal_argument_exception.json b/src/plugins/data/common/search/test_data/illegal_argument_exception.json new file mode 100644 index 00000000000000..ae48468abc209d --- /dev/null +++ b/src/plugins/data/common/search/test_data/illegal_argument_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + } + ], + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/index_not_found_exception.json b/src/plugins/data/common/search/test_data/index_not_found_exception.json new file mode 100644 index 00000000000000..dc892d95ae3974 --- /dev/null +++ b/src/plugins/data/common/search/test_data/index_not_found_exception.json @@ -0,0 +1,21 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + } + ], + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/json_e_o_f_exception.json b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json new file mode 100644 index 00000000000000..88134e1c6ea03b --- /dev/null +++ b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + } + ], + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/parsing_exception.json b/src/plugins/data/common/search/test_data/parsing_exception.json new file mode 100644 index 00000000000000..725a847aa0e3f5 --- /dev/null +++ b/src/plugins/data/common/search/test_data/parsing_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + } + ], + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/resource_not_found_exception.json b/src/plugins/data/common/search/test_data/resource_not_found_exception.json new file mode 100644 index 00000000000000..7f2a3b2e6e1439 --- /dev/null +++ b/src/plugins/data/common/search/test_data/resource_not_found_exception.json @@ -0,0 +1,13 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + } + ], + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/search_phase_execution_exception.json b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 00000000000000..ff6879f2b89609 --- /dev/null +++ b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,52 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + } + } + ], + "type" : "search_phase_execution_exception", + "reason" : "all shards failed", + "phase" : "query", + "grouped" : true, + "failed_shards" : [ + { + "shard" : 0, + "index" : ".kibana_11", + "node" : "b3HX8C96Q7q1zgfVLxEsPA", + "reason" : { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + }, + "caused_by" : { + "type" : "illegal_argument_exception", + "reason" : "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/x_content_parse_exception.json b/src/plugins/data/common/search/test_data/x_content_parse_exception.json new file mode 100644 index 00000000000000..cd6e1cb2c5977d --- /dev/null +++ b/src/plugins/data/common/search/test_data/x_content_parse_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object" + } + ], + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object", + "caused_by" : { + "type" : "json_parse_exception", + "reason" : "Unexpected character (''' (code 39)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (org.elasticsearch.common.bytes.AbstractBytesReference$BytesReferenceStreamInput); line: 5, column: 24]" + } + }, + "status" : 400 +} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 5b1462e5d506b3..f533af2db9672e 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2282,8 +2282,11 @@ export class SearchInterceptor { protected readonly deps: SearchInterceptorDeps; // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; + // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts + // // (undocumented) - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; + protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; // @internal protected pendingCount$: BehaviorSubject; // @internal (undocumented) @@ -2453,7 +2456,7 @@ export interface SearchSourceFields { // // @public export class SearchTimeoutError extends KbnError { - constructor(err: Error, mode: TimeoutErrorMode); + constructor(err: Record, mode: TimeoutErrorMode); // (undocumented) getErrorMessage(application: ApplicationStart): JSX.Element; // (undocumented) diff --git a/src/plugins/data/public/search/errors/es_error.test.tsx b/src/plugins/data/public/search/errors/es_error.test.tsx index adb422c1d18e75..6a4cb9c494b4f9 100644 --- a/src/plugins/data/public/search/errors/es_error.test.tsx +++ b/src/plugins/data/public/search/errors/es_error.test.tsx @@ -7,23 +7,22 @@ */ import { EsError } from './es_error'; -import { IEsError } from './types'; describe('EsError', () => { it('contains the same body as the wrapped error', () => { const error = { - body: { - attributes: { - error: { - type: 'top_level_exception_type', - reason: 'top-level reason', - }, + statusCode: 500, + message: 'nope', + attributes: { + error: { + type: 'top_level_exception_type', + reason: 'top-level reason', }, }, - } as IEsError; + } as any; const esError = new EsError(error); - expect(typeof esError.body).toEqual('object'); - expect(esError.body).toEqual(error.body); + expect(typeof esError.attributes).toEqual('object'); + expect(esError.attributes).toEqual(error.attributes); }); }); diff --git a/src/plugins/data/public/search/errors/es_error.tsx b/src/plugins/data/public/search/errors/es_error.tsx index fff06d2e1bfb64..d241eecfd8d5dd 100644 --- a/src/plugins/data/public/search/errors/es_error.tsx +++ b/src/plugins/data/public/search/errors/es_error.tsx @@ -11,19 +11,19 @@ import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { ApplicationStart } from 'kibana/public'; import { KbnError } from '../../../../kibana_utils/common'; import { IEsError } from './types'; -import { getRootCause, getTopLevelCause } from './utils'; +import { getRootCause } from './utils'; export class EsError extends KbnError { - readonly body: IEsError['body']; + readonly attributes: IEsError['attributes']; constructor(protected readonly err: IEsError) { super('EsError'); - this.body = err.body; + this.attributes = err.attributes; } public getErrorMessage(application: ApplicationStart) { const rootCause = getRootCause(this.err)?.reason; - const topLevelCause = getTopLevelCause(this.err)?.reason; + const topLevelCause = this.attributes?.reason; const cause = rootCause ?? topLevelCause; return ( diff --git a/src/plugins/data/public/search/errors/painless_error.test.tsx b/src/plugins/data/public/search/errors/painless_error.test.tsx new file mode 100644 index 00000000000000..929f25e234a604 --- /dev/null +++ b/src/plugins/data/public/search/errors/painless_error.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { coreMock } from '../../../../../core/public/mocks'; +const startMock = coreMock.createStart(); + +import { mount } from 'enzyme'; +import { PainlessError } from './painless_error'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; + +describe('PainlessError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should show reason and code', () => { + const e = new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }); + const component = mount(e.getErrorMessage(startMock.application)); + + const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode(); + + const failedShards = e.attributes?.failed_shards![0]; + const script = failedShards!.reason.script; + expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`); + + const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode(); + const stackTrace = failedShards!.reason.script_stack!.join('\n'); + expect(stackTraceElem.textContent).toBe(stackTrace); + + expect(component.find('EuiButton').length).toBe(1); + }); +}); diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 8a4248e48185bc..6d11f3a16b09e8 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -33,10 +33,12 @@ export class PainlessError extends EsError { return ( <> - {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { - defaultMessage: "Error executing Painless script: '{script}'.", - values: { script: rootCause?.script }, - })} + + {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { + defaultMessage: "Error executing Painless script: '{script}'", + values: { script: rootCause?.script }, + })} + {painlessStack ? ( diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx index ee2703b888bf17..6b9ce1b422481f 100644 --- a/src/plugins/data/public/search/errors/timeout_error.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.tsx @@ -24,7 +24,7 @@ export enum TimeoutErrorMode { */ export class SearchTimeoutError extends KbnError { public mode: TimeoutErrorMode; - constructor(err: Error, mode: TimeoutErrorMode) { + constructor(err: Record, mode: TimeoutErrorMode) { super(`Request timeout: ${JSON.stringify(err?.message)}`); this.mode = mode; } diff --git a/src/plugins/data/public/search/errors/types.ts b/src/plugins/data/public/search/errors/types.ts index d62cb311bf6a43..5806ef8676b9bd 100644 --- a/src/plugins/data/public/search/errors/types.ts +++ b/src/plugins/data/public/search/errors/types.ts @@ -6,57 +6,47 @@ * Public License, v 1. */ +import { KibanaServerError } from '../../../../kibana_utils/common'; + export interface FailedShard { shard: number; index: string; node: string; - reason: { + reason: Reason; +} + +export interface Reason { + type: string; + reason: string; + script_stack?: string[]; + position?: { + offset: number; + start: number; + end: number; + }; + lang?: string; + script?: string; + caused_by?: { type: string; reason: string; - script_stack: string[]; - script: string; - lang: string; - position: { - offset: number; - start: number; - end: number; - }; - caused_by: { - type: string; - reason: string; - }; }; } -export interface IEsError { - body: { - statusCode: number; - error: string; - message: string; - attributes?: { - error?: { - root_cause?: [ - { - lang: string; - script: string; - } - ]; - type: string; - reason: string; - failed_shards: FailedShard[]; - caused_by: { - type: string; - reason: string; - phase: string; - grouped: boolean; - failed_shards: FailedShard[]; - script_stack: string[]; - }; - }; - }; - }; +export interface IEsErrorAttributes { + type: string; + reason: string; + root_cause?: Reason[]; + failed_shards?: FailedShard[]; } +export type IEsError = KibanaServerError; + +/** + * Checks if a given errors originated from Elasticsearch. + * Those params are assigned to the attributes property of an error. + * + * @param e + */ export function isEsError(e: any): e is IEsError { - return !!e.body?.attributes; + return !!e.attributes; } diff --git a/src/plugins/data/public/search/errors/utils.ts b/src/plugins/data/public/search/errors/utils.ts index d140e713f9440d..7d303543a0c57d 100644 --- a/src/plugins/data/public/search/errors/utils.ts +++ b/src/plugins/data/public/search/errors/utils.ts @@ -6,19 +6,15 @@ * Public License, v 1. */ -import { IEsError } from './types'; +import { FailedShard } from './types'; +import { KibanaServerError } from '../../../../kibana_utils/common'; -export function getFailedShards(err: IEsError) { - const failedShards = - err.body?.attributes?.error?.failed_shards || - err.body?.attributes?.error?.caused_by?.failed_shards; +export function getFailedShards(err: KibanaServerError): FailedShard | undefined { + const errorInfo = err.attributes; + const failedShards = errorInfo?.failed_shards || errorInfo?.caused_by?.failed_shards; return failedShards ? failedShards[0] : undefined; } -export function getTopLevelCause(err: IEsError) { - return err.body?.attributes?.error; -} - -export function getRootCause(err: IEsError) { +export function getRootCause(err: KibanaServerError) { return getFailedShards(err)?.reason; } diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 5ae01eccdd920c..bfd73951c31c48 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -12,12 +12,15 @@ import { coreMock } from '../../../../core/public/mocks'; import { IEsSearchRequest } from '../../common/search'; import { SearchInterceptor } from './search_interceptor'; import { AbortError } from '../../../kibana_utils/public'; -import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors'; +import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors'; import { searchServiceMock } from './mocks'; import { ISearchStart, ISessionService } from '.'; import { bfetchPluginMock } from '../../../bfetch/public/mocks'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json'; +import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json'; + let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; let bfetchSetup: jest.Mocked; @@ -64,15 +67,9 @@ describe('SearchInterceptor', () => { test('Renders a PainlessError', async () => { searchInterceptor.showError( new PainlessError({ - body: { - attributes: { - error: { - failed_shards: { - reason: 'bananas', - }, - }, - }, - } as any, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }) ); expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); @@ -161,10 +158,8 @@ describe('SearchInterceptor', () => { describe('Should handle Timeout errors', () => { test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -177,10 +172,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show multiple times if not in a session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -198,10 +191,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once per each session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -219,10 +210,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once in a single session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -240,22 +229,9 @@ describe('SearchInterceptor', () => { test('Should throw Painless error on server error with OSS format', async () => { const mockResponse: any = { - result: 500, - body: { - attributes: { - error: { - failed_shards: [ - { - reason: { - lang: 'painless', - script_stack: ['a', 'b'], - reason: 'banana', - }, - }, - ], - }, - }, - }, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -265,6 +241,20 @@ describe('SearchInterceptor', () => { await expect(response.toPromise()).rejects.toThrow(PainlessError); }); + test('Should throw ES error on ES server error', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'resource_not_found_exception', + attributes: resourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(EsError); + }); + test('Observable should fail if user aborts (test merged signal)', async () => { const abortController = new AbortController(); fetchMock.mockImplementationOnce((options: any) => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f6ca9ef1a993de..6dfc8faea769ea 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { get, memoize } from 'lodash'; +import { memoize } from 'lodash'; import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs'; import { catchError, finalize } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; @@ -25,7 +25,11 @@ import { getHttpError, } from './errors'; import { toMountPoint } from '../../../kibana_react/public'; -import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public'; +import { + AbortError, + getCombinedAbortSignal, + KibanaServerError, +} from '../../../kibana_utils/public'; import { ISessionService } from './session'; export interface SearchInterceptorDeps { @@ -87,8 +91,12 @@ export class SearchInterceptor { * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. * @internal */ - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error { - if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') { + protected handleSearchError( + e: KibanaServerError | AbortError, + timeoutSignal: AbortSignal, + options?: ISearchOptions + ): Error { + if (timeoutSignal.aborted || e.message === 'Request timed out') { // Handle a client or a server side timeout const err = new SearchTimeoutError(e, this.getTimeoutMode()); @@ -96,7 +104,7 @@ export class SearchInterceptor { // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. this.showTimeoutError(err, options?.sessionId); return err; - } else if (options?.abortSignal?.aborted) { + } else if (e instanceof AbortError) { // In the case an application initiated abort, throw the existing AbortError. return e; } else if (isEsError(e)) { @@ -106,12 +114,13 @@ export class SearchInterceptor { return new EsError(e); } } else { - return e; + return e instanceof Error ? e : new Error(e.message); } } /** * @internal + * @throws `AbortError` | `ErrorLike` */ protected runSearch( request: IKibanaSearchRequest, @@ -234,7 +243,7 @@ export class SearchInterceptor { }); this.pendingCount$.next(this.pendingCount$.getValue() + 1); return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe( - catchError((e: Error) => { + catchError((e: Error | AbortError) => { return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts index 8e66729825e39c..eeef46381732e8 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts @@ -6,37 +6,56 @@ * Public License, v 1. */ +import { + elasticsearchClientMock, + MockedTransportRequestPromise, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../core/server/elasticsearch/client/mocks'; import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks'; import { esSearchStrategyProvider } from './es_search_strategy'; import { SearchStrategyDependencies } from '../types'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { KbnServerError } from '../../../../kibana_utils/server'; + describe('ES search strategy', () => { + const successBody = { + _shards: { + total: 10, + failed: 1, + skipped: 2, + successful: 7, + }, + }; + let mockedApiCaller: MockedTransportRequestPromise; + let mockApiCaller: jest.Mock<() => MockedTransportRequestPromise>; const mockLogger: any = { debug: () => {}, }; - const mockApiCaller = jest.fn().mockResolvedValue({ - body: { - _shards: { - total: 10, - failed: 1, - skipped: 2, - successful: 7, - }, - }, - }); - const mockDeps = ({ - uiSettingsClient: { - get: () => {}, - }, - esClient: { asCurrentUser: { search: mockApiCaller } }, - } as unknown) as SearchStrategyDependencies; + function getMockedDeps(err?: Record) { + mockApiCaller = jest.fn().mockImplementation(() => { + if (err) { + mockedApiCaller = elasticsearchClientMock.createErrorTransportRequestPromise(err); + } else { + mockedApiCaller = elasticsearchClientMock.createSuccessTransportRequestPromise( + successBody, + { statusCode: 200 } + ); + } + return mockedApiCaller; + }); - const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; + return ({ + uiSettingsClient: { + get: () => {}, + }, + esClient: { asCurrentUser: { search: mockApiCaller } }, + } as unknown) as SearchStrategyDependencies; + } - beforeEach(() => { - mockApiCaller.mockClear(); - }); + const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; it('returns a strategy with `search`', async () => { const esSearch = await esSearchStrategyProvider(mockConfig$, mockLogger); @@ -48,7 +67,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -64,7 +83,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -82,13 +101,109 @@ describe('ES search strategy', () => { params: { index: 'logstash-*' }, }, {}, - mockDeps + getMockedDeps() ) .subscribe((data) => { expect(data.isRunning).toBe(false); expect(data.isPartial).toBe(false); expect(data).toHaveProperty('loaded'); expect(data).toHaveProperty('rawResponse'); + expect(mockedApiCaller.abort).not.toBeCalled(); done(); })); + + it('can be aborted', async () => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + const abortController = new AbortController(); + abortController.abort(); + + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, { abortSignal: abortController.signal }, getMockedDeps()) + .toPromise(); + + expect(mockApiCaller).toBeCalled(); + expect(mockApiCaller.mock.calls[0][0]).toEqual({ + ...params, + track_total_hits: true, + }); + expect(mockedApiCaller.abort).toBeCalled(); + }); + + it('throws normalized error if ResponseError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(404); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(indexNotFoundException); + done(); + } + }); + + it('throws normalized error if ElasticsearchClientError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ElasticsearchClientError('This is a general ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws normalized error if ESClient throws unknown error', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new Error('ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws KbnServerError for unknown index type', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ indexType: 'banana', params }, {}, getMockedDeps()) + .toPromise(); + } catch (e) { + expect(mockApiCaller).not.toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.message).toBe('Unsupported index pattern type banana'); + expect(e.statusCode).toBe(400); + expect(e.errBody).toBe(undefined); + done(); + } + }); }); diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index a11bbe11f3f959..c176a50627b92e 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -15,13 +15,20 @@ import type { SearchUsage } from '../collectors'; import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils'; import { toKibanaSearchResponse } from './response_utils'; import { searchUsageObserver } from '../collectors/usage'; -import { KbnServerError } from '../../../../kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../kibana_utils/server'; export const esSearchStrategyProvider = ( config$: Observable, logger: Logger, usage?: SearchUsage ): ISearchStrategy => ({ + /** + * @param request + * @param options + * @param deps + * @throws `KbnServerError` + * @returns `Observable>` + */ search: (request, { abortSignal }, { esClient, uiSettingsClient }) => { // Only default index pattern type is supported here. // See data_enhanced for other type support. @@ -30,15 +37,19 @@ export const esSearchStrategyProvider = ( } const search = async () => { - const config = await config$.pipe(first()).toPromise(); - const params = { - ...(await getDefaultSearchParams(uiSettingsClient)), - ...getShardTimeout(config), - ...request.params, - }; - const promise = esClient.asCurrentUser.search>(params); - const { body } = await shimAbortSignal(promise, abortSignal); - return toKibanaSearchResponse(body); + try { + const config = await config$.pipe(first()).toPromise(); + const params = { + ...(await getDefaultSearchParams(uiSettingsClient)), + ...getShardTimeout(config), + ...request.params, + }; + const promise = esClient.asCurrentUser.search>(params); + const { body } = await shimAbortSignal(promise, abortSignal); + return toKibanaSearchResponse(body); + } catch (e) { + throw getKbnServerError(e); + } }; return from(search()).pipe(tap(searchUsageObserver(logger, usage))); diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts new file mode 100644 index 00000000000000..e30b7bdaa84022 --- /dev/null +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { catchError, first, map } from 'rxjs/operators'; +import { CoreStart, KibanaRequest } from 'src/core/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchClient, + ISearchOptions, +} from '../../../common/search'; +import { shimHitsTotal } from './shim_hits_total'; + +type GetScopedProider = (coreStart: CoreStart) => (request: KibanaRequest) => ISearchClient; + +export function registerBsearchRoute( + bfetch: BfetchServerSetup, + coreStartPromise: Promise<[CoreStart, {}, {}]>, + getScopedProvider: GetScopedProider +): void { + bfetch.addBatchProcessingRoute< + { request: IKibanaSearchRequest; options?: ISearchOptions }, + IKibanaSearchResponse + >('/internal/bsearch', (request) => { + return { + /** + * @param requestOptions + * @throws `KibanaServerError` + */ + onBatchItem: async ({ request: requestData, options }) => { + const coreStart = await coreStartPromise; + const search = getScopedProvider(coreStart[0])(request); + return search + .search(requestData, options) + .pipe( + first(), + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + catchError((err) => { + // Re-throw as object, to get attributes passed to the client + // eslint-disable-next-line no-throw-literal + throw { + message: err.message, + statusCode: err.statusCode, + attributes: err.errBody?.error, + }; + }) + ) + .toPromise(); + }, + }; + }); +} diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 6578774f65a3c8..fc30e2f29c3ef2 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -8,12 +8,12 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { ApiResponse } from '@elastic/elasticsearch'; import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; import { shimHitsTotal } from './shim_hits_total'; +import { getKbnServerError } from '../../../../kibana_utils/server'; import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..'; /** @internal */ @@ -48,6 +48,9 @@ interface CallMsearchDependencies { * @internal */ export function getCallMsearch(dependencies: CallMsearchDependencies) { + /** + * @throws KbnServerError + */ return async (params: { body: MsearchRequestBody; signal?: AbortSignal; @@ -61,28 +64,29 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { // trackTotalHits is not supported by msearch const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings); - const body = convertRequestBody(params.body, timeout); - - const promise = shimAbortSignal( - esClient.asCurrentUser.msearch( + try { + const promise = esClient.asCurrentUser.msearch( { - body, + body: convertRequestBody(params.body, timeout), }, { querystring: defaultParams, } - ), - params.signal - ); - const response = (await promise) as ApiResponse<{ responses: Array> }>; + ); + const response = await shimAbortSignal(promise, params.signal); - return { - body: { - ...response, + return { body: { - responses: response.body.responses?.map((r: SearchResponse) => shimHitsTotal(r)), + ...response, + body: { + responses: response.body.responses?.map((r: SearchResponse) => + shimHitsTotal(r) + ), + }, }, - }, - }; + }; + } catch (e) { + throw getKbnServerError(e); + } }; } diff --git a/src/plugins/data/server/search/routes/msearch.test.ts b/src/plugins/data/server/search/routes/msearch.test.ts index 02f200d5435dda..a847931a49123d 100644 --- a/src/plugins/data/server/search/routes/msearch.test.ts +++ b/src/plugins/data/server/search/routes/msearch.test.ts @@ -24,6 +24,8 @@ import { convertRequestBody } from './call_msearch'; import { registerMsearchRoute } from './msearch'; import { DataPluginStart } from '../../plugin'; import { dataPluginMock } from '../../mocks'; +import * as jsonEofException from '../../../common/search/test_data/json_e_o_f_exception.json'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; describe('msearch route', () => { let mockDataStart: MockedKeys; @@ -76,15 +78,52 @@ describe('msearch route', () => { }); }); - it('handler throws an error if the search throws an error', async () => { - const response = { - message: 'oh no', - body: { - error: 'oops', + it('handler returns an error response if the search throws an error', async () => { + const rejectedValue = Promise.reject( + new ResponseError({ + body: jsonEofException, + statusCode: 400, + meta: {} as any, + headers: [], + warnings: [], + }) + ); + const mockClient = { + msearch: jest.fn().mockReturnValue(rejectedValue), + }; + const mockContext = { + core: { + elasticsearch: { client: { asCurrentUser: mockClient } }, + uiSettings: { client: { get: jest.fn() } }, }, }; + const mockBody = { searches: [{ header: {}, body: {} }] }; + const mockQuery = {}; + const mockRequest = httpServerMock.createKibanaRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + registerMsearchRoute(mockCoreSetup.http.createRouter(), { getStartServices, globalConfig$ }); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.msearch).toBeCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('json_e_o_f_exception'); + expect(error.body.attributes).toBe(jsonEofException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = Promise.reject(new Error('What happened?')); const mockClient = { - msearch: jest.fn().mockReturnValue(Promise.reject(response)), + msearch: jest.fn().mockReturnValue(rejectedValue), }; const mockContext = { core: { @@ -106,11 +145,12 @@ describe('msearch route', () => { const handler = mockRouter.post.mock.calls[0][1]; await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockClient.msearch).toBeCalled(); + expect(mockClient.msearch).toBeCalledTimes(1); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('What happened?'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/routes/search.test.ts b/src/plugins/data/server/search/routes/search.test.ts index f47a42cf9d82b4..2cde6d19e4c187 100644 --- a/src/plugins/data/server/search/routes/search.test.ts +++ b/src/plugins/data/server/search/routes/search.test.ts @@ -12,11 +12,27 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { registerSearchRoute } from './search'; import { DataPluginStart } from '../../plugin'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { KbnServerError } from '../../../../kibana_utils/server'; describe('Search service', () => { let mockCoreSetup: MockedKeys>; + function mockEsError(message: string, statusCode: number, attributes?: Record) { + return new KbnServerError(message, statusCode, attributes); + } + + async function runMockSearch(mockContext: any, mockRequest: any, mockResponse: any) { + registerSearchRoute(mockCoreSetup.http.createRouter()); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + } + beforeEach(() => { + jest.clearAllMocks(); mockCoreSetup = coreMock.createSetup(); }); @@ -54,11 +70,7 @@ describe('Search service', () => { }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); - - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + await runMockSearch(mockContext, mockRequest, mockResponse); expect(mockContext.search.search).toBeCalled(); expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); @@ -68,14 +80,9 @@ describe('Search service', () => { }); }); - it('handler throws an error if the search throws an error', async () => { + it('handler returns an error response if the search throws a painless error', async () => { const rejectedValue = from( - Promise.reject({ - message: 'oh no', - body: { - error: 'oops', - }, - }) + Promise.reject(mockEsError('search_phase_execution_exception', 400, searchPhaseException)) ); const mockContext = { @@ -84,25 +91,69 @@ describe('Search service', () => { }, }; - const mockBody = { id: undefined, params: {} }; - const mockParams = { strategy: 'foo' }; const mockRequest = httpServerMock.createKibanaRequest({ - body: mockBody, - params: mockParams, + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); + await runMockSearch(mockContext, mockRequest, mockResponse); - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + // verify error + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('search_phase_execution_exception'); + expect(error.body.attributes).toBe(searchPhaseException.error); + }); + + it('handler returns an error response if the search throws an index not found error', async () => { + const rejectedValue = from( + Promise.reject(mockEsError('index_not_found_exception', 404, indexNotFoundException)) + ); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); + + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(404); + expect(error.body.message).toBe('index_not_found_exception'); + expect(error.body.attributes).toBe(indexNotFoundException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = from(Promise.reject(new Error('This is odd'))); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); - expect(mockContext.search.search).toBeCalled(); - expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('This is odd'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index f1a6fc09ee21f4..63593bbe84a088 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { pick } from 'lodash'; import { CoreSetup, @@ -18,7 +18,7 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { catchError, first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import type { @@ -64,6 +64,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; import { ConfigSchema } from '../../config'; import { SessionService, IScopedSessionService, ISessionService } from './session'; import { KbnServerError } from '../../../kibana_utils/server'; +import { registerBsearchRoute } from './routes/bsearch'; type StrategyMap = Record>; @@ -137,43 +138,7 @@ export class SearchService implements Plugin { ) ); - bfetch.addBatchProcessingRoute< - { request: IKibanaSearchResponse; options?: ISearchOptions }, - any - >('/internal/bsearch', (request) => { - const search = this.asScopedProvider(this.coreStart!)(request); - - return { - onBatchItem: async ({ request: requestData, options }) => { - return search - .search(requestData, options) - .pipe( - first(), - map((response) => { - return { - ...response, - ...{ - rawResponse: shimHitsTotal(response.rawResponse), - }, - }; - }), - catchError((err) => { - // eslint-disable-next-line no-throw-literal - throw { - statusCode: err.statusCode || 500, - body: { - message: err.message, - attributes: { - error: err.body?.error || err.message, - }, - }, - }; - }) - ) - .toPromise(); - }, - }; - }); + registerBsearchRoute(bfetch, core.getStartServices(), this.asScopedProvider); core.savedObjects.registerType(searchTelemetry); if (usageCollection) { @@ -285,10 +250,14 @@ export class SearchService implements Plugin { options: ISearchOptions, deps: SearchStrategyDependencies ) => { - const strategy = this.getSearchStrategy( - options.strategy - ); - return session.search(strategy, request, options, deps); + try { + const strategy = this.getSearchStrategy( + options.strategy + ); + return session.search(strategy, request, options, deps); + } catch (e) { + return throwError(e); + } }; private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => { diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 81bcb3b02e100e..21560b13288407 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"], + "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../bfetch/tsconfig.json" }, diff --git a/src/plugins/kibana_utils/common/errors/index.ts b/src/plugins/kibana_utils/common/errors/index.ts index 354cf1d504b287..f859e0728269a1 100644 --- a/src/plugins/kibana_utils/common/errors/index.ts +++ b/src/plugins/kibana_utils/common/errors/index.ts @@ -7,3 +7,4 @@ */ export * from './errors'; +export * from './types'; diff --git a/src/plugins/kibana_utils/common/errors/types.ts b/src/plugins/kibana_utils/common/errors/types.ts new file mode 100644 index 00000000000000..89e83586dc1157 --- /dev/null +++ b/src/plugins/kibana_utils/common/errors/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +export interface KibanaServerError { + statusCode: number; + message: string; + attributes?: T; +} diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts index f95ffe5c3d7b6f..821118ea4640dd 100644 --- a/src/plugins/kibana_utils/server/index.ts +++ b/src/plugins/kibana_utils/server/index.ts @@ -18,4 +18,4 @@ export { url, } from '../common'; -export { KbnServerError, reportServerError } from './report_server_error'; +export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error'; diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts index 664f34ca7ad518..01e80cfc7184d3 100644 --- a/src/plugins/kibana_utils/server/report_server_error.ts +++ b/src/plugins/kibana_utils/server/report_server_error.ts @@ -6,23 +6,42 @@ * Public License, v 1. */ +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { KibanaResponseFactory } from 'kibana/server'; import { KbnError } from '../common'; export class KbnServerError extends KbnError { - constructor(message: string, public readonly statusCode: number) { + public errBody?: Record; + constructor(message: string, public readonly statusCode: number, errBody?: Record) { super(message); + this.errBody = errBody; } } -export function reportServerError(res: KibanaResponseFactory, err: any) { +/** + * Formats any error thrown into a standardized `KbnServerError`. + * @param e `Error` or `ElasticsearchClientError` + * @returns `KbnServerError` + */ +export function getKbnServerError(e: Error) { + return new KbnServerError( + e.message ?? 'Unknown error', + e instanceof ResponseError ? e.statusCode : 500, + e instanceof ResponseError ? e.body : undefined + ); +} + +/** + * + * @param res Formats a `KbnServerError` into a server error response + * @param err + */ +export function reportServerError(res: KibanaResponseFactory, err: KbnServerError) { return res.customError({ statusCode: err.statusCode ?? 500, body: { message: err.message, - attributes: { - error: err.body?.error || err.message, - }, + attributes: err.errBody?.error, }, }); } diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts new file mode 100644 index 00000000000000..504680d28bf83a --- /dev/null +++ b/test/api_integration/apis/search/bsearch.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import request from 'superagent'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; + +function parseBfetchResponse(resp: request.Response): Array> { + return resp.text + .trim() + .split('\n') + .map((item) => JSON.parse(item)); +} + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('bsearch', () => { + describe('post', () => { + it('should return 200 a single response', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + const jsonBody = JSON.parse(resp.text); + + expect(resp.status).to.be(200); + expect(jsonBody.id).to.be(0); + expect(jsonBody.result.isPartial).to.be(false); + expect(jsonBody.result.isRunning).to.be(false); + expect(jsonBody.result).to.have.property('rawResponse'); + }); + + it('should return a batch of successful resposes', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + const parsedResponse = parseBfetchResponse(resp); + expect(parsedResponse).to.have.length(2); + parsedResponse.forEach((responseJson) => { + expect(responseJson.result.isPartial).to.be(false); + expect(responseJson.result.isRunning).to.be(false); + expect(responseJson.result).to.have.property('rawResponse'); + }); + }); + + it('should return error for not found strategy', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + options: { + strategy: 'wtf', + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 404, 'Search strategy wtf not found'); + }); + }); + + it('should return 400 when index type is provided in OSS', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + indexType: 'baad', + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'Unsupported index pattern type baad'); + }); + }); + + describe('painless', () => { + before(async () => { + await esArchiver.loadIfNeeded( + '../../../functional/fixtures/es_archiver/logstash_functional' + ); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + it('should return 400 for Painless error', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: painlessErrReq, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'search_phase_execution_exception', true); + }); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index 2f21825d6902f8..6e90bf0f22c51f 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./bsearch')); loadTestFile(require.resolve('./msearch')); }); } diff --git a/test/api_integration/apis/search/painless_err_req.ts b/test/api_integration/apis/search/painless_err_req.ts new file mode 100644 index 00000000000000..6fbf6565d7a9e1 --- /dev/null +++ b/test/api_integration/apis/search/painless_err_req.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export const painlessErrReq = { + params: { + index: 'log*', + body: { + size: 500, + fields: ['*'], + script_fields: { + invalid_scripted_field: { + script: { + source: 'invalid', + lang: 'painless', + }, + }, + }, + stored_fields: ['*'], + query: { + bool: { + filter: [ + { + match_all: {}, + }, + { + range: { + '@timestamp': { + gte: '2015-01-19T12:27:55.047Z', + lte: '2021-01-19T12:27:55.047Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + }, +}; diff --git a/test/api_integration/apis/search/search.ts b/test/api_integration/apis/search/search.ts index fc13189a407537..155705f81fa8a9 100644 --- a/test/api_integration/apis/search/search.ts +++ b/test/api_integration/apis/search/search.ts @@ -8,11 +8,21 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('search', () => { + before(async () => { + await esArchiver.loadIfNeeded('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); describe('post', () => { it('should return 200 when correctly formatted searches are provided', async () => { const resp = await supertest @@ -28,13 +38,37 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); + expect(resp.status).to.be(200); expect(resp.body.isPartial).to.be(false); expect(resp.body.isRunning).to.be(false); expect(resp.body).to.have.property('rawResponse'); }); - it('should return 404 when if no strategy is provided', async () => - await supertest + it('should return 200 if terminated early', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + terminateAfter: 1, + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(200); + + expect(resp.status).to.be(200); + expect(resp.body.isPartial).to.be(false); + expect(resp.body.isRunning).to.be(false); + expect(resp.body.rawResponse.terminated_early).to.be(true); + }); + + it('should return 404 when if no strategy is provided', async () => { + const resp = await supertest .post(`/internal/search`) .send({ body: { @@ -43,7 +77,10 @@ export default function ({ getService }: FtrProviderContext) { }, }, }) - .expect(404)); + .expect(404); + + verifyErrorResponse(resp.body, 404); + }); it('should return 404 when if unknown strategy is provided', async () => { const resp = await supertest @@ -56,6 +93,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); + + verifyErrorResponse(resp.body, 404); expect(resp.body.message).to.contain('banana not found'); }); @@ -74,11 +113,33 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); + verifyErrorResponse(resp.body, 400); + expect(resp.body.message).to.contain('Unsupported index pattern'); }); + it('should return 400 with illegal ES argument', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + timeout: 1, // This should be a time range string! + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); + }); + it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/es`) .send({ params: { @@ -89,16 +150,26 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); + }); + + it('should return 400 for a painless error', async () => { + const resp = await supertest.post(`/internal/search/es`).send(painlessErrReq).expect(400); + + verifyErrorResponse(resp.body, 400, 'search_phase_execution_exception', true); }); }); describe('delete', () => { it('should return 404 when no search id provided', async () => { - await supertest.delete(`/internal/search/es`).send().expect(404); + const resp = await supertest.delete(`/internal/search/es`).send().expect(404); + verifyErrorResponse(resp.body, 404); }); it('should return 400 when trying a delete on a non supporting strategy', async () => { const resp = await supertest.delete(`/internal/search/es/123`).send().expect(400); + verifyErrorResponse(resp.body, 400); expect(resp.body.message).to.contain("Search strategy es doesn't support cancellations"); }); }); diff --git a/test/api_integration/apis/search/verify_error.ts b/test/api_integration/apis/search/verify_error.ts new file mode 100644 index 00000000000000..a5754ff47973ec --- /dev/null +++ b/test/api_integration/apis/search/verify_error.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; + +export const verifyErrorResponse = ( + r: any, + expectedCode: number, + message?: string, + shouldHaveAttrs?: boolean +) => { + expect(r.statusCode).to.be(expectedCode); + if (message) { + expect(r.message).to.be(message); + } + if (shouldHaveAttrs) { + expect(r).to.have.property('attributes'); + expect(r.attributes).to.have.property('root_cause'); + } else { + expect(r).not.to.have.property('attributes'); + } +}; diff --git a/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 00000000000000..b79a396445e3d8 --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,229 @@ +{ + "error": { + "root_cause": [ + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]" + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + } + ], + "type": "search_phase_execution_exception", + "reason": "all shards failed", + "phase": "query", + "grouped": true, + "failed_shards": [ + { + "shard": 0, + "index": ".apm-agent-configuration", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".apm-custom-link", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana-event-log-8.0.0-000001", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]", + "caused_by": { + "type": "illegal_argument_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]", + "caused_by": { + "type": "date_time_parse_exception", + "reason": "Text '2021-01-19T12:2755.047Z' could not be parsed, unparsed text found at index 16" + } + } + } + }, + { + "shard": 0, + "index": ".kibana_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana_task_manager_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".security-7", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status": 400 +} \ No newline at end of file diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 1a6fc724e2cf21..22b0f3272ff7d8 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -9,10 +9,16 @@ import { EnhancedSearchInterceptor } from './search_interceptor'; import { CoreSetup, CoreStart } from 'kibana/public'; import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { ISessionService, SearchTimeoutError, SearchSessionState } from 'src/plugins/data/public'; +import { + ISessionService, + SearchTimeoutError, + SearchSessionState, + PainlessError, +} from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; import { BehaviorSubject } from 'rxjs'; +import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; const timeTravel = (msToRun = 0) => { jest.advanceTimersByTime(msToRun); @@ -99,6 +105,33 @@ describe('EnhancedSearchInterceptor', () => { }); }); + describe('errors', () => { + test('Should throw Painless error on server error with OSS format', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const response = searchInterceptor.search({ + params: {}, + }); + await expect(response.toPromise()).rejects.toThrow(PainlessError); + }); + + test('Renders a PainlessError', async () => { + searchInterceptor.showError( + new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }) + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + }); + describe('search', () => { test('should resolve immediately if first call returns full result', async () => { const responses = [ @@ -342,7 +375,8 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - error: 'oh no', + statusCode: 500, + message: 'oh no', id: 1, }, isError: true, @@ -364,7 +398,8 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(10); expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBe(responses[1].value); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts index 3230895da77059..b2ddd0310f8f59 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts @@ -7,6 +7,10 @@ import { enhancedEsSearchStrategyProvider } from './es_search_strategy'; import { BehaviorSubject } from 'rxjs'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server/search'; +import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import * as indexNotFoundException from '../../../../../src/plugins/data/common/search/test_data/index_not_found_exception.json'; +import * as xContentParseException from '../../../../../src/plugins/data/common/search/test_data/x_content_parse_exception.json'; const mockAsyncResponse = { body: { @@ -145,6 +149,54 @@ describe('ES search strategy', () => { expect(request).toHaveProperty('wait_for_completion_timeout'); expect(request).toHaveProperty('keep_alive'); }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); describe('cancel', () => { @@ -160,6 +212,33 @@ describe('ES search strategy', () => { const request = mockDeleteCaller.mock.calls[0][0]; expect(request).toEqual({ id }); }); + + it('throws normalized error on ResponseError', async () => { + const errResponse = new ResponseError({ + body: xContentParseException, + statusCode: 400, + headers: {}, + warnings: [], + meta: {} as any, + }); + mockDeleteCaller.mockRejectedValue(errResponse); + + const id = 'some_id'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.cancel!(id, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockDeleteCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(400); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(xContentParseException); + }); }); describe('extend', () => { @@ -176,5 +255,27 @@ describe('ES search strategy', () => { const request = mockGetCaller.mock.calls[0][0]; expect(request).toEqual({ id, keep_alive: keepAlive }); }); + + it('throws normalized error on ElasticsearchClientError', async () => { + const errResponse = new ElasticsearchClientError('something is wrong with EsClient'); + mockGetCaller.mockRejectedValue(errResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.extend!(id, keepAlive, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockGetCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 54ed59b30952af..694d9807b5a4d3 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -6,7 +6,7 @@ import type { Observable } from 'rxjs'; import type { IScopedClusterClient, Logger, SharedGlobalConfig } from 'kibana/server'; -import { first, tap } from 'rxjs/operators'; +import { catchError, first, tap } from 'rxjs/operators'; import { SearchResponse } from 'elasticsearch'; import { from } from 'rxjs'; import type { @@ -33,7 +33,7 @@ import { } from './request_utils'; import { toAsyncKibanaSearchResponse } from './response_utils'; import { AsyncSearchResponse } from './types'; -import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; export const enhancedEsSearchStrategyProvider = ( config$: Observable, @@ -41,7 +41,11 @@ export const enhancedEsSearchStrategyProvider = ( usage?: SearchUsage ): ISearchStrategy => { async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { - await esClient.asCurrentUser.asyncSearch.delete({ id }); + try { + await esClient.asCurrentUser.asyncSearch.delete({ id }); + } catch (e) { + throw getKbnServerError(e); + } } function asyncSearch( @@ -70,7 +74,10 @@ export const enhancedEsSearchStrategyProvider = ( return pollSearch(search, cancel, options).pipe( tap((response) => (id = response.id)), - tap(searchUsageObserver(logger, usage)) + tap(searchUsageObserver(logger, usage)), + catchError((e) => { + throw getKbnServerError(e); + }) ); } @@ -90,40 +97,72 @@ export const enhancedEsSearchStrategyProvider = ( ...params, }; - const promise = esClient.asCurrentUser.transport.request({ - method, - path, - body, - querystring, - }); + try { + const promise = esClient.asCurrentUser.transport.request({ + method, + path, + body, + querystring, + }); - const esResponse = await shimAbortSignal(promise, options?.abortSignal); - const response = esResponse.body as SearchResponse; - return { - rawResponse: response, - ...getTotalLoaded(response), - }; + const esResponse = await shimAbortSignal(promise, options?.abortSignal); + const response = esResponse.body as SearchResponse; + return { + rawResponse: response, + ...getTotalLoaded(response), + }; + } catch (e) { + throw getKbnServerError(e); + } } return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ search: (request, options: IAsyncSearchOptions, deps) => { logger.debug(`search ${JSON.stringify(request.params) || request.id}`); + if (request.indexType && request.indexType !== 'rollup') { + throw new KbnServerError('Unknown indexType', 400); + } if (request.indexType === undefined) { return asyncSearch(request, options, deps); - } else if (request.indexType === 'rollup') { - return from(rollupSearch(request, options, deps)); } else { - throw new KbnServerError('Unknown indexType', 400); + return from(rollupSearch(request, options, deps)); } }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ cancel: async (id, options, { esClient }) => { logger.debug(`cancel ${id}`); await cancelAsyncSearch(id, esClient); }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ extend: async (id, keepAlive, options, { esClient }) => { logger.debug(`extend ${id} by ${keepAlive}`); - await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + try { + await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + } catch (e) { + throw getKbnServerError(e); + } }, }; }; diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index c4b09276880d99..29bfd71cb32b40 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -14,7 +14,8 @@ "config.ts", "../../../typings/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 - "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json" + "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json", + "common/search/test_data/*.json" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index 0c08b834a27783..2115976bcced1a 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -90,6 +91,23 @@ export default function ({ getService }: FtrProviderContext) { expect(resp2.body.isRunning).to.be(false); }); + it('should fail without kbn-xref header', async () => { + const resp = await supertest + .post(`/internal/search/ese`) + .send({ + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'Request must contain a kbn-xsrf header.'); + }); + it('should return 400 when unknown index type is provided', async () => { const resp = await supertest .post(`/internal/search/ese`) @@ -106,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('Unknown indexType'); + verifyErrorResponse(resp.body, 400, 'Unknown indexType'); }); it('should return 400 if invalid id is provided', async () => { @@ -124,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 404 if unkown id is provided', async () => { @@ -143,12 +161,11 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); - - expect(resp.body.message).to.contain('resource_not_found_exception'); + verifyErrorResponse(resp.body, 404, 'resource_not_found_exception', true); }); it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/ese`) .set('kbn-xsrf', 'foo') .send({ @@ -160,6 +177,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); }); @@ -186,8 +205,7 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); - - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 400 if rollup search is without non-existent index', async () => { @@ -207,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should rollup search', async () => { @@ -241,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send() .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should delete a search', async () => { From af337ce4edb6f09b69ab0513785c664be3e82f12 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Sun, 31 Jan 2021 08:37:58 -0600 Subject: [PATCH 36/38] [Presentation Team] Migrate to Typescript Project References (#86019) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/input_control_vis/tsconfig.json | 21 ++++++++ tsconfig.json | 1 + tsconfig.refs.json | 11 ++-- .../server/demodata/get_demo_rows.ts | 2 + .../renderers/error/index.tsx | 2 +- .../filters/dropdown_filter/index.tsx | 2 +- .../canvas_plugin_src/renderers/table.tsx | 2 +- .../export/export/export_app.component.tsx | 2 +- .../apps/home/home_app/home_app.component.tsx | 2 +- .../workpad/workpad_app/workpad_telemetry.tsx | 2 +- .../asset_manager/asset.component.tsx | 2 +- .../asset_manager/asset_manager.component.tsx | 2 +- .../confirm_modal/confirm_modal.tsx | 2 +- .../page_preview/page_preview.component.tsx | 2 +- .../components/toolbar/toolbar.component.tsx | 2 +- .../workpad_config.component.tsx | 2 +- .../refresh_control.component.tsx | 2 +- .../canvas/public/functions/filters.ts | 2 +- x-pack/plugins/canvas/public/functions/pie.ts | 2 +- .../canvas/public/functions/plot/index.ts | 2 +- .../canvas/public/functions/timelion.ts | 2 +- x-pack/plugins/canvas/public/functions/to.ts | 2 +- .../lib/template_from_react_component.tsx | 2 +- .../canvas/server/sample_data/index.ts | 4 +- .../shareable_runtime/context/actions.ts | 2 +- .../canvas/shareable_runtime/test/index.ts | 3 ++ x-pack/plugins/canvas/tsconfig.json | 52 ++++++++++++++++++ x-pack/plugins/canvas/types/state.ts | 2 +- .../server/routes/lib/get_document_payload.ts | 2 +- x-pack/plugins/reporting/tsconfig.json | 31 +++++++++++ x-pack/tsconfig.json | 54 ++++++++++--------- x-pack/tsconfig.refs.json | 42 ++++++++------- 32 files changed, 190 insertions(+), 75 deletions(-) create mode 100644 src/plugins/input_control_vis/tsconfig.json create mode 100644 x-pack/plugins/canvas/tsconfig.json create mode 100644 x-pack/plugins/reporting/tsconfig.json diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json new file mode 100644 index 00000000000000..bef7bc394a6ccd --- /dev/null +++ b/src/plugins/input_control_vis/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../data/tsconfig.json"}, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] +} diff --git a/tsconfig.json b/tsconfig.json index 2647ac9a9d75e8..d8fb2804242bc3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "src/plugins/es_ui_shared/**/*", "src/plugins/expressions/**/*", "src/plugins/home/**/*", + "src/plugins/input_control_vis/**/*", "src/plugins/inspector/**/*", "src/plugins/kibana_legacy/**/*", "src/plugins/kibana_react/**/*", diff --git a/tsconfig.refs.json b/tsconfig.refs.json index fa1b533a3dd383..9a65b385b78202 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -2,12 +2,12 @@ "include": [], "references": [ { "path": "./src/core/tsconfig.json" }, - { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, { "path": "./src/plugins/advanced_settings/tsconfig.json" }, { "path": "./src/plugins/apm_oss/tsconfig.json" }, { "path": "./src/plugins/bfetch/tsconfig.json" }, { "path": "./src/plugins/charts/tsconfig.json" }, { "path": "./src/plugins/console/tsconfig.json" }, + { "path": "./src/plugins/dashboard/tsconfig.json" }, { "path": "./src/plugins/data/tsconfig.json" }, { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/discover/tsconfig.json" }, @@ -15,8 +15,6 @@ { "path": "./src/plugins/es_ui_shared/tsconfig.json" }, { "path": "./src/plugins/expressions/tsconfig.json" }, { "path": "./src/plugins/home/tsconfig.json" }, - { "path": "./src/plugins/dashboard/tsconfig.json" }, - { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/inspector/tsconfig.json" }, { "path": "./src/plugins/kibana_legacy/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, @@ -26,16 +24,17 @@ { "path": "./src/plugins/maps_legacy/tsconfig.json" }, { "path": "./src/plugins/navigation/tsconfig.json" }, { "path": "./src/plugins/newsfeed/tsconfig.json" }, + { "path": "./src/plugins/presentation_util/tsconfig.json" }, { "path": "./src/plugins/region_map/tsconfig.json" }, - { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/saved_objects_management/tsconfig.json" }, { "path": "./src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "./src/plugins/presentation_util/tsconfig.json" }, + { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/security_oss/tsconfig.json" }, { "path": "./src/plugins/share/tsconfig.json" }, { "path": "./src/plugins/spaces_oss/tsconfig.json" }, - { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/tile_map/tsconfig.json" }, { "path": "./src/plugins/timelion/tsconfig.json" }, { "path": "./src/plugins/ui_actions/tsconfig.json" }, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts index 58a2354b5cf384..ff5a4506ab82ae 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts @@ -5,8 +5,10 @@ */ import { cloneDeep } from 'lodash'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ci from './ci.json'; import { DemoRows } from './demo_rows_types'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import shirts from './shirts.json'; import { getFunctionErrors } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx index a9296bd9a12417..238b2edc3bd6d7 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx @@ -12,7 +12,7 @@ import { Popover } from '../../../public/components/popover'; import { RendererStrings } from '../../../i18n'; import { RendererFactory } from '../../../types'; -interface Config { +export interface Config { error: Error; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index bfc36932a8a073..6c1dd086c86673 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -15,7 +15,7 @@ import { RendererStrings } from '../../../../i18n'; const { dropdownFilter: strings } = RendererStrings; -interface Config { +export interface Config { /** The column to use within the exactly function */ column: string; /** diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx index ada159e07f6ae9..4933b1b4ba51dc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx @@ -12,7 +12,7 @@ import { RendererFactory, Style, Datatable } from '../../types'; const { dropdownFilter: strings } = RendererStrings; -interface TableArguments { +export interface TableArguments { font?: Style; paginate: boolean; perPage: number; diff --git a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx index 03121e749d0dc4..f26408b1200f18 100644 --- a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx @@ -13,7 +13,7 @@ import { WorkpadPage } from '../../../components/workpad_page'; import { Link } from '../../../components/link'; import { CanvasWorkpad } from '../../../../types'; -interface Props { +export interface Props { workpad: CanvasWorkpad; selectedPageIndex: number; initializeWorkpad: () => void; diff --git a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx index 3c2e989cc8e51c..7fbdc24c112a1d 100644 --- a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx @@ -11,7 +11,7 @@ import { WorkpadManager } from '../../../components/workpad_manager'; // @ts-expect-error untyped local import { setDocTitle } from '../../../lib/doc_title'; -interface Props { +export interface Props { onLoad: () => void; } diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx index 981334ff8d9f25..3697d5dad2dae8 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx @@ -46,7 +46,7 @@ interface ResolvedArgs { [keys: string]: any; } -interface ElementsLoadedTelemetryProps extends PropsFromRedux { +export interface ElementsLoadedTelemetryProps extends PropsFromRedux { workpad: Workpad; } diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx index ed000741bc5420..d94802bf2a772a 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx @@ -28,7 +28,7 @@ import { ComponentStrings } from '../../../i18n'; const { Asset: strings } = ComponentStrings; -interface Props { +export interface Props { /** The asset to be rendered */ asset: AssetType; /** The function to execute when the user clicks 'Create' */ diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index 98f3d8b48829d5..6c1b546b49aa19 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -33,7 +33,7 @@ import { ComponentStrings } from '../../../i18n'; const { AssetManager: strings } = ComponentStrings; -interface Props { +export interface Props { /** The assets to display within the modal */ assets: AssetType[]; /** Function to invoke when the modal is closed */ diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx index 31a75acbba4ecc..9d0a5e0a9f51d7 100644 --- a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx @@ -8,7 +8,7 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; -interface Props { +export interface Props { isOpen: boolean; title?: string; message: string; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx index fd1dc869d60ec8..da1fe8473e36de 100644 --- a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx +++ b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx @@ -10,7 +10,7 @@ import { DomPreview } from '../dom_preview'; import { PageControls } from './page_controls'; import { CanvasPage } from '../../../types'; -interface Props { +export interface Props { isWriteable: boolean; page: Pick; height: number; diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index 7151e72a44780c..d33ba57050d4b3 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -31,7 +31,7 @@ const { Toolbar: strings } = ComponentStrings; type TrayType = 'pageManager' | 'expression'; -interface Props { +export interface Props { isWriteable: boolean; selectedElement?: CanvasElement; selectedPageNumber: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx index a7424882f10722..4068272bbaf113 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx @@ -30,7 +30,7 @@ import { ComponentStrings } from '../../../i18n'; const { WorkpadConfig: strings } = ComponentStrings; -interface Props { +export interface Props { size: { height: number; width: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx index d651e649128f93..023d87c7c35656 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx @@ -12,7 +12,7 @@ import { ToolTipShortcut } from '../../tool_tip_shortcut'; import { ComponentStrings } from '../../../../i18n'; const { WorkpadHeaderRefreshControlSettings: strings } = ComponentStrings; -interface Props { +export interface Props { doRefresh: MouseEventHandler; inFlight: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index fdb5d69d35515b..70120ccad6f541 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -15,7 +15,7 @@ import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { group: string[]; ungrouped: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts index ab3f1b932dc3cb..e7cf153b9cd0f2 100644 --- a/x-pack/plugins/canvas/public/functions/pie.ts +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -61,7 +61,7 @@ export interface Pie { options: PieOptions; } -interface Arguments { +export interface Arguments { palette: PaletteOutput; seriesStyle: SeriesStyle[]; radius: number | 'auto'; diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index a4661dc3401df4..79aa11cfa2d80a 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -17,7 +17,7 @@ import { getTickHash } from './get_tick_hash'; import { getFunctionHelp } from '../../../i18n'; import { AxisConfig, PointSeries, Render, SeriesStyle, Legend } from '../../../types'; -interface Arguments { +export interface Arguments { seriesStyle: SeriesStyle[]; defaultStyle: SeriesStyle; palette: PaletteOutput; diff --git a/x-pack/plugins/canvas/public/functions/timelion.ts b/x-pack/plugins/canvas/public/functions/timelion.ts index 947972fa310c9a..3018540e5bf8eb 100644 --- a/x-pack/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/plugins/canvas/public/functions/timelion.ts @@ -15,7 +15,7 @@ import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; -interface Arguments { +export interface Arguments { query: string; interval: string; from: string; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index 36b2d3f9f04c66..c8ac4f714e5c42 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -10,7 +10,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { type: string[]; } diff --git a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx index f4e715b1bbc491..95225cf13ff3bb 100644 --- a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx +++ b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx @@ -11,7 +11,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { ErrorBoundary } from '../components/enhance/error_boundary'; import { ArgumentHandlers } from '../../types/arguments'; -interface Props { +export interface Props { renderError: Function; } diff --git a/x-pack/plugins/canvas/server/sample_data/index.ts b/x-pack/plugins/canvas/server/sample_data/index.ts index 212d9f51328315..9c9ecb718fd5f7 100644 --- a/x-pack/plugins/canvas/server/sample_data/index.ts +++ b/x-pack/plugins/canvas/server/sample_data/index.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ecommerceSavedObjects from './ecommerce_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import flightsSavedObjects from './flights_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import webLogsSavedObjects from './web_logs_saved_objects.json'; import { loadSampleData } from './load_sample_data'; diff --git a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts index 8c88afbadfd9ef..a36435688505d4 100644 --- a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts +++ b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts @@ -17,7 +17,7 @@ export enum CanvasShareableActions { SET_TOOLBAR_AUTOHIDE = 'SET_TOOLBAR_AUTOHIDE', } -interface FluxAction { +export interface FluxAction { type: T; payload: P; } diff --git a/x-pack/plugins/canvas/shareable_runtime/test/index.ts b/x-pack/plugins/canvas/shareable_runtime/test/index.ts index 288dd0dc3a5be8..f0d2ebcc20128a 100644 --- a/x-pack/plugins/canvas/shareable_runtime/test/index.ts +++ b/x-pack/plugins/canvas/shareable_runtime/test/index.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import hello from './workpads/hello.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import austin from './workpads/austin.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import test from './workpads/test.json'; export * from './utils'; diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json new file mode 100644 index 00000000000000..3e3986082e2076 --- /dev/null +++ b/x-pack/plugins/canvas/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "__fixtures__/**/*", + "canvas_plugin_src/**/*", + "common/**/*", + "i18n/**/*", + "public/**/*", + "server/**/*", + "shareable_runtime/**/*", + "storybook/**/*", + "tasks/mocks/*", + "types/**/*", + "**/*.json", + ], + "exclude": [ + // these files are too large and upset tsc, so we exclude them + "server/sample_data/*.json", + "canvas_plugin_src/functions/server/demodata/*.json", + "shareable_runtime/test/workpads/*.json", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/bfetch/tsconfig.json"}, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/inspector/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/visualizations/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 03bb931dc9b261..33f913563daacf 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -52,7 +52,7 @@ type ExpressionType = | Style | Range; -interface ExpressionRenderable { +export interface ExpressionRenderable { state: 'ready' | 'pending'; value: Render | null; error: null; diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index b154978d041f45..7706aa9d650c7c 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -13,7 +13,7 @@ import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; import { ExportTypeDefinition } from '../../types'; -interface ErrorFromPayload { +export interface ErrorFromPayload { message: string; } diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json new file mode 100644 index 00000000000000..88e8d343f4700f --- /dev/null +++ b/x-pack/plugins/reporting/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/share/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 4b161e3559849e..1be6b5cf84cda3 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,6 +7,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", + "plugins/canvas/**/*", "plugins/console_extensions/**/*", "plugins/data_enhanced/**/*", "plugins/discover_enhanced/**/*", @@ -23,6 +24,7 @@ "plugins/maps/**/*", "plugins/maps_file_upload/**/*", "plugins/maps_legacy_licensing/**/*", + "plugins/reporting/**/*", "plugins/searchprofiler/**/*", "plugins/security_solution/cypress/**/*", "plugins/task_manager/**/*", @@ -49,15 +51,13 @@ }, "references": [ { "path": "../src/core/tsconfig.json" }, - { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, - { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/bfetch/tsconfig.json" }, { "path": "../src/plugins/charts/tsconfig.json" }, { "path": "../src/plugins/console/tsconfig.json" }, { "path": "../src/plugins/dashboard/tsconfig.json" }, - { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/data/tsconfig.json" }, { "path": "../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/embeddable/tsconfig.json" }, { "path": "../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../src/plugins/expressions/tsconfig.json" }, @@ -67,53 +67,55 @@ { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/navigation/tsconfig.json" }, { "path": "../src/plugins/newsfeed/tsconfig.json" }, - { "path": "../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../src/plugins/presentation_util/tsconfig.json" }, { "path": "../src/plugins/saved_objects_management/tsconfig.json" }, { "path": "../src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "../src/plugins/presentation_util/tsconfig.json" }, + { "path": "../src/plugins/saved_objects/tsconfig.json" }, { "path": "../src/plugins/security_oss/tsconfig.json" }, { "path": "../src/plugins/share/tsconfig.json" }, - { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../src/plugins/url_forwarding/tsconfig.json" }, + { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/ui_actions/tsconfig.json" }, { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "./plugins/actions/tsconfig.json"}, + { "path": "./plugins/alerts/tsconfig.json"}, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, { "path": "./plugins/discover_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, { "path": "./plugins/enterprise_search/tsconfig.json" }, + { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, - { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, - { "path": "./plugins/event_log/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/lens/tsconfig.json" }, - { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/maps_file_upload/tsconfig.json" }, { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/actions/tsconfig.json"}, - { "path": "./plugins/alerts/tsconfig.json"}, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/stack_alerts/tsconfig.json"}, - { "path": "./plugins/license_management/tsconfig.json" }, - { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/watcher/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index f5b35c9429a1c5..ed209cd2415861 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -3,38 +3,40 @@ "references": [ { "path": "./plugins/actions/tsconfig.json"}, { "path": "./plugins/alerts/tsconfig.json"}, - { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, - { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, - { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/event_log/tsconfig.json"}, { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, - { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, - { "path": "./plugins/enterprise_search/tsconfig.json" }, - { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/maps_file_upload/tsconfig.json" }, { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/reporting/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/stack_alerts/tsconfig.json"}, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/license_management/tsconfig.json" }, - { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/watcher/tsconfig.json" } ] } From 4f43096c64c4b27205ecd8fd3aecfd1426da6892 Mon Sep 17 00:00:00 2001 From: Nicolas Ruflin Date: Mon, 1 Feb 2021 10:28:49 +0100 Subject: [PATCH 37/38] [Fleet] Remove comments around experimental registry (#89830) The experimental registry was used for the 7.8 release but since then was not touched anymore. Because of this it should not show up in the code anymore even if it is commented out. --- .../plugins/fleet/server/services/epm/registry/registry_url.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts index efc25cc2efb5db..4f17a2b88670a5 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts @@ -11,12 +11,10 @@ import { appContextService, licenseService } from '../../'; const PRODUCTION_REGISTRY_URL_CDN = 'https://epr.elastic.co'; // const STAGING_REGISTRY_URL_CDN = 'https://epr-staging.elastic.co'; -// const EXPERIMENTAL_REGISTRY_URL_CDN = 'https://epr-experimental.elastic.co/'; const SNAPSHOT_REGISTRY_URL_CDN = 'https://epr-snapshot.elastic.co'; // const PRODUCTION_REGISTRY_URL_NO_CDN = 'https://epr.ea-web.elastic.dev'; // const STAGING_REGISTRY_URL_NO_CDN = 'https://epr-staging.ea-web.elastic.dev'; -// const EXPERIMENTAL_REGISTRY_URL_NO_CDN = 'https://epr-experimental.ea-web.elastic.dev/'; // const SNAPSHOT_REGISTRY_URL_NO_CDN = 'https://epr-snapshot.ea-web.elastic.dev'; const getDefaultRegistryUrl = (): string => { From c2f53a96ebb6e4a5a9a8e4dcbbcde33aa4d1f20d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 1 Feb 2021 10:40:38 +0100 Subject: [PATCH 38/38] [Search Sessions][Dashboard] Clear search session when navigating from dashboard route (#89749) --- src/plugins/dashboard/public/application/dashboard_app.tsx | 7 +++++++ .../apps/dashboard/async_search/send_to_background.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 7ea181715717bc..6955365ebca3f1 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -265,6 +265,13 @@ export function DashboardApp({ }; }, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]); + // clear search session when leaving dashboard route + useEffect(() => { + return () => { + data.search.session.clear(); + }; + }, [data.search.session]); + return (
    {savedDashboard && dashboardStateManager && dashboardContainer && viewMode && ( diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts index 7e878e763bfc1c..3e417551c3cb94 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts @@ -96,6 +96,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // should leave session state untouched await PageObjects.dashboard.switchToEditMode(); await searchSessions.expectState('restored'); + + // navigating to a listing page clears the session + await PageObjects.dashboard.gotoDashboardLandingPage(); + await searchSessions.missingOrFail(); }); }); }