diff --git a/.eslintrc.js b/.eslintrc.js index b730b88a1b9cf0..60d26cbfbab738 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,11 @@ const ELASTIC_LICENSE_HEADER = ` */ `; +const allMochaRulesOff = {}; +Object.keys(require('eslint-plugin-mocha').rules).forEach(k => { + allMochaRulesOff['mocha/' + k] = 'off'; +}); + module.exports = { root: true, @@ -519,9 +524,7 @@ module.exports = { */ { files: ['test/harden/*.js'], - rules: { - 'mocha/handle-done-callback': 'off', // TODO: Find a way to disable all mocha rules - }, + rules: allMochaRulesOff, }, /** diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 552b2666eb3ead..a9af160d020841 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -88,7 +88,6 @@ /x-pack/test/functional/services/ml.ts @elastic/ml-ui # ML team owns the transform plugin, ES team added here for visibility # because the plugin lives in Kibana's Elasticsearch management section. -/x-pack/legacy/plugins/transform/ @elastic/ml-ui @elastic/es-ui /x-pack/plugins/transform/ @elastic/ml-ui @elastic/es-ui /x-pack/test/functional/apps/transform/ @elastic/ml-ui /x-pack/test/functional/services/transform_ui/ @elastic/ml-ui diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.addsearchstrategy.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.addsearchstrategy.md deleted file mode 100644 index 119e7fbe625364..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.addsearchstrategy.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [addSearchStrategy](./kibana-plugin-plugins-data-public.addsearchstrategy.md) - -## addSearchStrategy variable - -Signature: - -```typescript -addSearchStrategy: (searchStrategy: SearchStrategyProvider) => void -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.defaultsearchstrategy.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.defaultsearchstrategy.md deleted file mode 100644 index d6a71cf561bc21..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.defaultsearchstrategy.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [defaultSearchStrategy](./kibana-plugin-plugins-data-public.defaultsearchstrategy.md) - -## defaultSearchStrategy variable - -Signature: - -```typescript -defaultSearchStrategy: SearchStrategyProvider -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.essearchstrategyprovider.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.essearchstrategyprovider.md deleted file mode 100644 index 1394c6b868546a..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.essearchstrategyprovider.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [esSearchStrategyProvider](./kibana-plugin-plugins-data-public.essearchstrategyprovider.md) - -## esSearchStrategyProvider variable - -Signature: - -```typescript -esSearchStrategyProvider: TSearchStrategyProvider -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getdefaultquery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getdefaultquery.md new file mode 100644 index 00000000000000..5e6627880333e8 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getdefaultquery.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [getDefaultQuery](./kibana-plugin-plugins-data-public.getdefaultquery.md) + +## getDefaultQuery() function + +Signature: + +```typescript +export declare function getDefaultQuery(language?: QueryLanguage): { + query: string; + language: QueryLanguage; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| language | QueryLanguage | | + +Returns: + +`{ + query: string; + language: QueryLanguage; +}` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.hassearchstategyforindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.hassearchstategyforindexpattern.md deleted file mode 100644 index 94608e7a868205..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.hassearchstategyforindexpattern.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [hasSearchStategyForIndexPattern](./kibana-plugin-plugins-data-public.hassearchstategyforindexpattern.md) - -## hasSearchStategyForIndexPattern variable - -Signature: - -```typescript -hasSearchStategyForIndexPattern: (indexPattern: IndexPattern) => boolean -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.indextype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.indextype.md new file mode 100644 index 00000000000000..55b43efc52305e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.indextype.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IEsSearchRequest](./kibana-plugin-plugins-data-public.iessearchrequest.md) > [indexType](./kibana-plugin-plugins-data-public.iessearchrequest.indextype.md) + +## IEsSearchRequest.indexType property + +Signature: + +```typescript +indexType?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.md index 7a40725a67e5fd..ed24ca613cdf6e 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchrequest.md @@ -14,5 +14,6 @@ export interface IEsSearchRequest extends IKibanaSearchRequest | Property | Type | Description | | --- | --- | --- | +| [indexType](./kibana-plugin-plugins-data-public.iessearchrequest.indextype.md) | string | | | [params](./kibana-plugin-plugins-data-public.iessearchrequest.params.md) | SearchParams | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 4b85461e64097e..ce1375d277b75f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -74,32 +74,26 @@ | [RangeFilterParams](./kibana-plugin-plugins-data-public.rangefilterparams.md) | | | [RefreshInterval](./kibana-plugin-plugins-data-public.refreshinterval.md) | | | [SavedQuery](./kibana-plugin-plugins-data-public.savedquery.md) | | -| [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) | | | [SavedQueryService](./kibana-plugin-plugins-data-public.savedqueryservice.md) | | | [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | | | [SearchStrategyProvider](./kibana-plugin-plugins-data-public.searchstrategyprovider.md) | | -| [TimefilterSetup](./kibana-plugin-plugins-data-public.timefiltersetup.md) | | | [TimeRange](./kibana-plugin-plugins-data-public.timerange.md) | | ## Variables | Variable | Description | | --- | --- | -| [addSearchStrategy](./kibana-plugin-plugins-data-public.addsearchstrategy.md) | | | [baseFormattersPublic](./kibana-plugin-plugins-data-public.baseformatterspublic.md) | | | [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-public.castestokbnfieldtypename.md) | Get the KbnFieldType name for an esType string | | [connectToQueryState](./kibana-plugin-plugins-data-public.connecttoquerystate.md) | Helper to setup two-way syncing of global data and a state container | | [createSavedQueryService](./kibana-plugin-plugins-data-public.createsavedqueryservice.md) | | -| [defaultSearchStrategy](./kibana-plugin-plugins-data-public.defaultsearchstrategy.md) | | | [ES\_SEARCH\_STRATEGY](./kibana-plugin-plugins-data-public.es_search_strategy.md) | | | [esFilters](./kibana-plugin-plugins-data-public.esfilters.md) | | | [esKuery](./kibana-plugin-plugins-data-public.eskuery.md) | | | [esQuery](./kibana-plugin-plugins-data-public.esquery.md) | | -| [esSearchStrategyProvider](./kibana-plugin-plugins-data-public.essearchstrategyprovider.md) | | | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [FilterBar](./kibana-plugin-plugins-data-public.filterbar.md) | | | [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {Array} | -| [hasSearchStategyForIndexPattern](./kibana-plugin-plugins-data-public.hassearchstategyforindexpattern.md) | | | [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | | [SearchBar](./kibana-plugin-plugins-data-public.searchbar.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.description.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.description.md deleted file mode 100644 index 859935480357c1..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.description.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) > [description](./kibana-plugin-plugins-data-public.savedqueryattributes.description.md) - -## SavedQueryAttributes.description property - -Signature: - -```typescript -description: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.filters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.filters.md deleted file mode 100644 index c2c1ac681802bf..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.filters.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) > [filters](./kibana-plugin-plugins-data-public.savedqueryattributes.filters.md) - -## SavedQueryAttributes.filters property - -Signature: - -```typescript -filters?: Filter[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.md deleted file mode 100644 index 612be6a1dabc60..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) - -## SavedQueryAttributes interface - -Signature: - -```typescript -export interface SavedQueryAttributes -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [description](./kibana-plugin-plugins-data-public.savedqueryattributes.description.md) | string | | -| [filters](./kibana-plugin-plugins-data-public.savedqueryattributes.filters.md) | Filter[] | | -| [query](./kibana-plugin-plugins-data-public.savedqueryattributes.query.md) | Query | | -| [timefilter](./kibana-plugin-plugins-data-public.savedqueryattributes.timefilter.md) | SavedQueryTimeFilter | | -| [title](./kibana-plugin-plugins-data-public.savedqueryattributes.title.md) | string | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.query.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.query.md deleted file mode 100644 index 96673fc3a8fdec..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.query.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) > [query](./kibana-plugin-plugins-data-public.savedqueryattributes.query.md) - -## SavedQueryAttributes.query property - -Signature: - -```typescript -query: Query; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.timefilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.timefilter.md deleted file mode 100644 index b4edb059a3dfde..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.timefilter.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) > [timefilter](./kibana-plugin-plugins-data-public.savedqueryattributes.timefilter.md) - -## SavedQueryAttributes.timefilter property - -Signature: - -```typescript -timefilter?: SavedQueryTimeFilter; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.title.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.title.md deleted file mode 100644 index 99ae1b83e88341..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.savedqueryattributes.title.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SavedQueryAttributes](./kibana-plugin-plugins-data-public.savedqueryattributes.md) > [title](./kibana-plugin-plugins-data-public.savedqueryattributes.title.md) - -## SavedQueryAttributes.title property - -Signature: - -```typescript -title: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.history.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.history.md deleted file mode 100644 index b2ef4a92c5fefb..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.history.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimefilterSetup](./kibana-plugin-plugins-data-public.timefiltersetup.md) > [history](./kibana-plugin-plugins-data-public.timefiltersetup.history.md) - -## TimefilterSetup.history property - -Signature: - -```typescript -history: TimeHistoryContract; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.md deleted file mode 100644 index 3375b415e923b7..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimefilterSetup](./kibana-plugin-plugins-data-public.timefiltersetup.md) - -## TimefilterSetup interface - - -Signature: - -```typescript -export interface TimefilterSetup -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [history](./kibana-plugin-plugins-data-public.timefiltersetup.history.md) | TimeHistoryContract | | -| [timefilter](./kibana-plugin-plugins-data-public.timefiltersetup.timefilter.md) | TimefilterContract | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.timefilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.timefilter.md deleted file mode 100644 index 897ace53a282dd..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timefiltersetup.timefilter.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimefilterSetup](./kibana-plugin-plugins-data-public.timefiltersetup.md) > [timefilter](./kibana-plugin-plugins-data-public.timefiltersetup.timefilter.md) - -## TimefilterSetup.timefilter property - -Signature: - -```typescript -timefilter: TimefilterContract; -``` diff --git a/docs/getting-started/tutorial-discovering.asciidoc b/docs/getting-started/tutorial-discovering.asciidoc index bbffb2187f0cfe..355477286d445a 100644 --- a/docs/getting-started/tutorial-discovering.asciidoc +++ b/docs/getting-started/tutorial-discovering.asciidoc @@ -1,20 +1,19 @@ [[tutorial-discovering]] === Discover your data -Using *Discover*, you can enter +Using *Discover*, enter an {ref}/query-dsl-query-string-query.html#query-string-syntax[Elasticsearch query] to search your data and filter the results. . Open *Discover*. + -The current index pattern appears below the filter bar, in this case `shakes*`. -You might need to click *New* in the menu bar to refresh the data. +The `shakes*` index pattern appears. -. Click the caret to the right of the current index pattern, and select `ba*`. +. To make `ba*` the current index, click the index pattern dropdown, then select `ba*`. + By default, all fields are shown for each matching document. -. In the search field, enter the following string: +. In the search field, enter: + [source,text] account_number<100 AND balance>47500 @@ -25,11 +24,10 @@ excess of 47,500. Results appear for account numbers 8, 32, 78, 85, and 97. [role="screenshot"] image::images/tutorial-discover-2.png[] + -. To choose which -fields to display, hover the pointer over the list of *Available fields* -and then click *add* next to each field you want include as a column in the table. +. Hover over the list of *Available fields*, then +click *add* next to each field you want include as a column in the table. + -For example, if you add the `account_number` field, the display changes to a list of five +For example, when you add the `account_number` field, the display changes to a list of five account numbers. + [role="screenshot"] diff --git a/docs/management/managing-fields.asciidoc b/docs/management/managing-fields.asciidoc index b54f4fe5194adf..1a1bcec10ab509 100644 --- a/docs/management/managing-fields.asciidoc +++ b/docs/management/managing-fields.asciidoc @@ -1,8 +1,8 @@ [[managing-fields]] -== Index Patterns and Fields +== Index patterns and fields The *Index patterns* UI helps you create and manage -the index patterns that retrieve your data from Elasticsearch. +the index patterns that retrieve your data from {es}. [role="screenshot"] image::images/management-index-patterns.png[] @@ -10,8 +10,8 @@ image::images/management-index-patterns.png[] [float] === Create an index pattern -An index pattern is the glue that connects Kibana to your Elasticsearch data. Create an -index pattern whenever you load your own data into Kibana. To get started, +An index pattern is the glue that connects {kib} to your {es} data. Create an +index pattern whenever you load your own data into {kib}. To get started, click *Create index pattern*, and then follow the guided steps. Refer to <> for the types of index patterns that you can create. @@ -33,7 +33,7 @@ you create is automatically designated as the default pattern. The default index pattern is loaded when you open *Discover*. * *Refresh the index fields list.* You can refresh the index fields list to -pick up any newly-added fields. Doing so also resets Kibana’s popularity counters +pick up any newly-added fields. Doing so also resets the {kib} popularity counters for the fields. The popularity counters are used in *Discover* to sort fields in lists. * [[delete-pattern]]*Delete the index pattern.* This action removes the pattern from the list of @@ -60,7 +60,7 @@ Kibana has field formatters for the following field types: * <> [[field-formatters-string]] -=== String Field Formatters +=== String field formatters String fields support the `String` and `Url` formatters. @@ -69,7 +69,7 @@ include::field-formatters/string-formatter.asciidoc[] include::field-formatters/url-formatter.asciidoc[] [[field-formatters-date]] -=== Date Field Formatters +=== Date field formatters Date fields support the `Date`, `Url`, and `String` formatters. @@ -81,19 +81,19 @@ include::field-formatters/string-formatter.asciidoc[] include::field-formatters/url-formatter.asciidoc[] [[field-formatters-geopoint]] -=== Geographic Point Field Formatters +=== Geographic point field formatters Geographic point fields support the `String` formatter. include::field-formatters/string-formatter.asciidoc[] [[field-formatters-numeric]] -=== Numeric Field Formatters +=== Numeric field formatters Numeric fields support the `Url`, `Bytes`, `Duration`, `Number`, `Percentage`, `String`, and `Color` formatters. The `Bytes`, `Number`, and `Percentage` formatters enable you to choose the display formats of numbers in this field using -the <> syntax that Kibana maintains. +the <> syntax that {kib} maintains. include::field-formatters/url-formatter.asciidoc[] @@ -104,25 +104,22 @@ include::field-formatters/duration-formatter.asciidoc[] include::field-formatters/color-formatter.asciidoc[] [[scripted-fields]] -=== Scripted Fields +=== Scripted fields -Scripted fields compute data on the fly from the data in your Elasticsearch indices. Scripted field data is shown on -the Discover tab as part of the document data, and you can use scripted fields in your visualizations. -Scripted field values are computed at query time so they aren't indexed and cannot be searched using Kibana's default -query language. However they can be queried using Kibana's new <>. Scripted -fields are also supported in the filter bar. +Scripted fields compute data on the fly from the data in your {es} indices. The data is shown on +the Discover tab as part of the document data, and you can use scripted fields in your visualizations. You query scripted fields with the <>, and can filter them using the filter bar. The scripted field values are computed at query time, so they aren't indexed and cannot be searched using the {kib} default +query language. WARNING: Computing data on the fly with scripted fields can be very resource intensive and can have a direct impact on -Kibana's performance. Keep in mind that there's no built-in validation of a scripted field. If your scripts are +{kib} performance. Keep in mind that there's no built-in validation of a scripted field. If your scripts are buggy, you'll get exceptions whenever you try to view the dynamically generated data. -When you define a scripted field in Kibana, you have a choice of scripting languages. Starting with 5.0, the default +When you define a scripted field in {kib}, you have a choice of scripting languages. In 5.0 and later, the default options are {ref}/modules-scripting-expression.html[Lucene expressions] and {ref}/modules-scripting-painless.html[Painless]. -While you can use other scripting languages if you enable dynamic scripting for them in Elasticsearch, this is not recommended +While you can use other scripting languages if you enable dynamic scripting for them in {es}, this is not recommended because they cannot be sufficiently {ref}/modules-scripting-security.html[sandboxed]. -WARNING: Use of Groovy, JavaScript, and Python scripting is deprecated starting in Elasticsearch 5.0, and support for those -scripting languages will be removed in the future. +WARNING: In 5.0 and later, Groovy, JavaScript, and Python scripting are deprecated and unsupported. You can reference any single value numeric field in your expressions, for example: @@ -130,44 +127,40 @@ You can reference any single value numeric field in your expressions, for exampl doc['field_name'].value ---- -For more background on scripted fields and additional examples, refer to this blog: -https://www.elastic.co/blog/using-painless-kibana-scripted-fields[Using Painless in Kibana scripted fields] +For more information on scripted fields and additional examples, refer to +https://www.elastic.co/blog/using-painless-kibana-scripted-fields[Using Painless in {kib} scripted fields] [float] [[create-scripted-field]] -=== Creating a Scripted Field -To create a scripted field: +=== Create a scripted field -. Go to *Management > Kibana > Index Patterns* +. Go to *Management > {kib} > Index Patterns* . Select the index pattern you want to add a scripted field to. -. Go to the pattern's *Scripted fields* tab. -. Click *Add scripted field*. +. Go to the *Scripted fields* tab for the index pattern, then click *Add scripted field*. . Enter a name for the scripted field. . Enter the expression that you want to use to compute a value on the fly from your index data. . Click *Create field*. -For more information about scripted fields in Elasticsearch, see +For more information about scripted fields in {es}, see {ref}/modules-scripting.html[Scripting]. [float] [[update-scripted-field]] -=== Updating a Scripted Field -To modify a scripted field: +=== Update a scripted field -. Go to *Management > Kibana > Index Patterns* -. Click the index pattern's *Scripted fields* tab. +. Go to *Management > {kib} > Index Patterns* +. Click the *Scripted fields* tab for the index pattern. . Click the *Edit* button for the scripted field you want to change. -. Make your changes and then click *Save field* to update the field. +. Make your changes, then click *Save field*. -WARNING: Keep in mind that there's no built-in validation of a scripted field. If your scripts are buggy, you'll get +WARNING: Built-in validation is unsupported for scripted fields. If your scripts are buggy, you'll get exceptions whenever you try to view the dynamically generated data. [float] [[delete-scripted-field]] -=== Deleting a Scripted Field -To delete a scripted field: +=== Delete a scripted field -. Go to *Management > Kibana > Index Patterns* -. Click the index pattern's *Scripted fields* tab. -. Click the *Delete* button for the scripted field you want to remove. -. Click *Delete* in the confirmation window. +. Go to *Management > {kib} > Index Patterns* +. Click the *Scripted fields* tab for the index pattern. +. Click *Delete* for the scripted field you want to remove. +. Click *Delete* on the confirmation window. diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 8ad5330f3fda59..fd835bde83322b 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -64,4 +64,9 @@ This page has moved. Please see <>. [role="exclude",id="tilemap"] == Coordinate map -This page has moved. Please see <>. +This page has moved. Please see <>. + +[role="exclude",id="visualize-maps"] +== Maps + +This page has moved. Please see <>. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 80d04c260e25f3..71bb7b81ea4208 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -220,15 +220,13 @@ requests. Supported on Elastic Cloud Enterprise. `map.includeElasticMapsService:`:: *Default: true* Set to false to disable connections to Elastic Maps Service. When `includeElasticMapsService` is turned off, only the vector layers configured by `map.regionmap` -and the tile layer configured by `map.tilemap.url` will be available in the <>, -<>, and <>. +and the tile layer configured by `map.tilemap.url` will be available in <>. `map.proxyElasticMapsServiceInMaps:`:: *Default: false* Set to true to proxy all <> Elastic Maps Service requests through the Kibana server. -This setting does not impact <> and <>. [[regionmap-settings]] `map.regionmap:`:: Specifies additional vector layers for -use in <> visualizations. Supported on {ece}. Each layer +use in <> visualizations. Supported on {ece}. Each layer object points to an external vector file that contains a geojson FeatureCollection. The file must use the https://en.wikipedia.org/wiki/World_Geodetic_System[WGS84 coordinate reference system (ESPG:4326)] diff --git a/docs/user/visualize.asciidoc b/docs/user/visualize.asciidoc index f6be2040e3e8c1..ebc2f404d43c19 100644 --- a/docs/user/visualize.asciidoc +++ b/docs/user/visualize.asciidoc @@ -40,11 +40,7 @@ data sets. <>:: * *<>* — Displays geospatial data in {kib}. -* *Coordinate map* — Displays points on a map using a geohash aggregation. - -* *Region map* — Merges any structured map data onto a shape. - -* *Heat map* — Displays shaded cells within a matrix. +* <>:: Display shaded cells within a matrix. <>:: diff --git a/docs/visualize/tilemap.asciidoc b/docs/visualize/tilemap.asciidoc index 349fa681a97776..51342847080e0e 100644 --- a/docs/visualize/tilemap.asciidoc +++ b/docs/visualize/tilemap.asciidoc @@ -1,116 +1,14 @@ -[[visualize-maps]] -== Maps - -To tell a story and answer questions about your geographical data, you can create several types of interactive maps with Visualize. - -Visualize supports the following maps: - -* *Coordinate* — Display latitude and longitude coordinates that are associated to the specified bucket aggregation. - -* *Region* — Display colored boundary vector shapes using a gradient. Darker colors indicate larger values, and lighter colors indicate smaller values. - -* *Heat* — Display graphical representations of data where the individual values are represented by colors. - -NOTE: The maps in Visualize have been replaced with <>, which offers more functionality. - -[float] -[[coordinate-map]] -=== Coordinate map - -Use a coordinate map when your data set includes latitude and longitude values. For example, use a coordinate map to see the varying popularity of destination airports using the sample flight data. - -[role="screenshot"] -image::images/visualize_coordinate_map_example.png[] - -[float] -[[build-coordinate-map]] -==== Build a coordinate map - -Configure the `kibana.yml` settings and add the aggregations. - -. Configure the following `kibana.yml` settings: - -* Set `xpack.maps.showMapVisualizationTypes` to `true`. - -* To use a tile service provider for coordinate maps other than https://www.elastic.co/elastic-maps-service[Elastic Maps Service], configure the <>. - -. To display your data on the coordinate map, use the following aggregations: - -* <> - -* <> - -. Specify the geohash bucket aggregation options: - -* *Precision* slider — Determines the granularity of the results displayed on the map. To show the *Precision* slider, deselect *Change precision on map zoom*. For information on the area specified by each precision level, refer to {ref}/search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator[geohash grid]. -+ -NOTE: Higher precisions increase memory usage for the browser that displays {kib} and the underlying -{es} cluster. - -* *Place markers off grid (use {ref}/search-aggregations-metrics-geocentroid-aggregation.html[geocentroid])* — When you selected, the markers are -placed in the center of all documents in the bucket, and a more accurate visualization is created. When deselected, the markers are placed in the center -of the geohash grid cell. -+ -NOTE: When you have multiple values in the geo_point, the coordinate map is unable to accurately calculate the geo_centroid. - -[float] -[[navigate-coordinate-map]] -==== Navigate the coordinate map - -To navigate the coordinate map, use the navigation options. - -* To move the map center, click and hold anywhere on the map and move the cursor. - -* To change the zoom level, click *Zoom In* or *Zoom out* image:images/viz-zoom.png[]. - -* To automatically crop the map boundaries to the -geohash buckets that have at least one result, click *Fit Data Bounds* image:images/viz-fit-bounds.png[]. - -[float] -[[region-map]] -=== Region map - -Use region maps when you want to show statistical data on a geographic area, such as a county, country, province, or state. For example, use a region map if you want to see the average sales for each country with the sample eCommerce order data. - -[role="screenshot"] -image::images/visualize_region_map_example.png[] - -[float] -[[build-region-maps]] -==== Build a region map - -Configure the `kibana.yml` settings and add the aggregations. - -. In `kibana.yml`, set `xpack.maps.showMapVisualizationTypes` to `true`. - -. To display your data on the region map, use the following aggregations: - -* <> -* <> -* <> - -[float] -[[navigate-region-map]] -==== Navigate the region map - -To navigate the region map, use the navigation options. - -* To change the zoom level, click *Zoom In* or *Zoom out* image:images/viz-zoom.png[]. - -* To automatically crop the map boundaries, click *Fit Data Bounds* image:images/viz-fit-bounds.png[]. - -[float] [[heat-map]] -=== Heat map +== Heat map -Use heat maps when your data set includes categorical data. For example, use a heat map to see the flights of origin countries compared to destination countries using the sample flight data. +Display graphical representations of data where the individual values are represented by colors. Use heat maps when your data set includes categorical data. For example, use a heat map to see the flights of origin countries compared to destination countries using the sample flight data. [role="screenshot"] image::images/visualize_heat_map_example.png[] [float] [[build-heat-map]] -==== Build a heat map +=== Build a heat map To display your data on the heat map, use the supported aggregations. @@ -123,7 +21,7 @@ Heat maps support the following aggregations: [float] [[navigate-heatmap]] -==== Change the color ranges +=== Change the color ranges When only one color displays on the heat map, you might need to change the color ranges. diff --git a/rfcs/images/pulse_diagram.png b/rfcs/images/pulse_diagram.png new file mode 100644 index 00000000000000..a104fad0fe133c Binary files /dev/null and b/rfcs/images/pulse_diagram.png differ diff --git a/rfcs/text/0008_pulse.md b/rfcs/text/0008_pulse.md new file mode 100644 index 00000000000000..e2543d310aa386 --- /dev/null +++ b/rfcs/text/0008_pulse.md @@ -0,0 +1,316 @@ +- Start Date: 2020-02-07 +- RFC PR: [#57108](https://github.com/elastic/kibana/pull/57108) +- Kibana Issue: (leave this empty) + +# Table of contents + +- [Summary](#summary) +- [Motivation](#motivation) +- [Detailed design](#detailed-design) + - [Concepts](#concepts) + - [Architecture](#architecture) + 1. [Remote Pulse Service](#1-remote-pulse-service) + - [Deployment](#deployment) + - [Endpoints](#endpoints) + - [Authenticate](#authenticate) + - [Opt-In|Out](#opt-inout) + - [Inject telemetry](#inject-telemetry) + - [Retrieve instructions](#retrieve-instructions) + - [Data model](#data-model) + - [Access Control](#access-control) + 2. [Local Pulse Service](#2-local-pulse-service) + - [Data storage](#data-storage) + - [Sending telemetry](#sending-telemetry) + - [Instruction polling](#instruction-polling) +- [Drawbacks](#drawbacks) +- [Alternatives](#alternatives) +- [Adoption strategy](#adoption-strategy) +- [How we teach this](#how-we-teach-this) +- [Unresolved questions](#unresolved-questions) + +# Summary + +Evolve our telemetry to collect more diverse data, enhance our products with that data and engage with users by enabling: + +1. _Two-way_ communication link between us and our products. +2. Flexibility to collect diverse data and different granularity based on the type of data. +3. Enhanced features in our products, allowing remote-driven _small tweaks_ to existing builds. +4. All this while still maintaining transparency about what we send and making sure we don't track any of the user's data. + +# Basic example + +There is a POC implemented in the branch [`pulse_poc`](https://github.com/elastic/kibana/tree/pulse_poc) in this repo. + +It covers the following scenarios: + +- Track the behaviour of our users in the UI, reporting UI events throughout our platform. +- Report to Elastic when an unexpected error occurs and keep track of it. When it's fixed, it lets the user know, encouraging them to update to their deployment to the latest release (PR [#56724](https://github.com/elastic/kibana/pull/56724)). +- Keep track of the notifications and news in the newsfeed to know when they are read/kept unseen. This might help us on improving the way we communicate updates to the user (PR [#53596](https://github.com/elastic/kibana/pull/53596)). +- Provide a cost estimate for running that cluster in Elastic Cloud, so the user is well-informed about our up-to-date offering and can decide accordingly (PR [#56324](https://github.com/elastic/kibana/pull/56324)). +- Customised "upgrade guide" from your current version to the latest (PR [#56556](https://github.com/elastic/kibana/pull/56556)). + +![image](../images/pulse_diagram.png) +_Basic example of the architecture_ + +# Motivation + +Based on our current telemetry, we have many _lessons learned_ we want to tackle: + +- It only supports one type of data: + - It makes simple tasks like reporting aggregations of usage based on a number of days [an overengineered solution](https://github.com/elastic/kibana/issues/46599#issuecomment-545024137) + - When reporting arrays (i.e.: `ui_metrics`), it cannot be consumed, making the data useless. +- _One index to rule them all_: +The current unique document structure comes at a price: + - People consuming that information finding it hard to understand each element in the document ([[DISCUSS] Data dictionary for product usage data](https://github.com/elastic/telemetry/issues/211)) + - Maintaining the mappings is a tedious and risky process. It involved increasing the setting for the limit of fields in a mapping and reindexing documents (now millions of them). + - We cannot fully control the data we insert in the documents: If we set `mappings.dynamic: 'strict'`, we'll reject all the documents containing more information than the actually mapped, losing all the other content we do want to receive. +- Opt-out ratio: +We want to reduce the number of `opt-out`s by providing some valuable feedback to our users so that they want to turn telemetry ON because they do benefit from it. + +# Detailed design + +This design is going to be tackled by introducing some common concepts to be used by the main two main components in this architecture: + +1. Remote Pulse Service (RPS) +2. Local Pulse Service (LPS) + +After that, it explains how we envision the architecture and design of each of those components. + +## Concepts + +There are some new concepts we'd like to introduce with this new way of reporting telemetry: + +- **Deployment Hash ID** +This is the _anonymised_ random ID assigned for a deployment. It is used to link multiple pieces of information for further analysis like cross-referencing different bits of information from different sources. +- **Channels** +This is each stream of data that have common information. Typically each channel will have a well defined source of information, different to the rest. It will also result in a different structure to the rest of channels. However, all the channels will maintain a minimum piece of common schema for cross-references (like **Deployment Hash ID** and **timestamp**). +- **Instructions** +These are the messages generated in the form of feedback to the different channels. +Typically, channels will follow a bi-directional communication process _(Local <-> Remote)_ but there might be channels that do not generate any kind of instruction _(Local -> Remote)_ and, similarly, some other channels that do not provide any telemetry at all, but allows Pulse to send updates to our products _(Local <- Remote)_. + +## Phased implementation + +At the moment of writing this document, anyone can push _fake_ telemetry data to our Telemetry cluster. They only need to know the public encryption key, the endpoint and the format of the data, all of that easily retrievable. We take that into consideration when analysing the data we have at the moment and it is a risk we are OK with for now. + +But, given that we aim to provide feedback to the users and clusters in the form of instructions, the **Security and Integrity of the information** is critical. We need to come up with a solution that ensures the instructions are created based on data that was uniquely created (signed?) by the source. If we cannot ensure that, we should not allow that piece of information to be used in the generation of the instructions for that cluster and we should mark it so we know it could be maliciously injected when using it in our analysis. + +But also, we want to be able to ship the benefits of Pulse on every release. That's why we are thinking on a phased release, starting with limited functionality and evolving to the final complete vision of this product. This RFC suggests the following phased implementation: + +1. **Be able to ingest granular data** +With the introduction of the **channels**, we can start receiving granular data that will help us all on our analysis. At this point, the same _security_ features as the current telemetry are considered: The payload is encrypted by the Kibana server so no mediator can spoof the data. +The same risks as the current telemetry still apply at this point: anyone can _impersonate_ and send the data on behalf of another cluster, making the collected information useless. +Because this information cannot be used to generate any instruction, we may not care about the **Deployment Hash ID** at this stage. This means no authentication is required to push data. +The works at this point in time will be focused on creating the initial infraestructure, receiving early data and start with the migration of the current telemetry into the new channel-based model. Finally, start exploring the new visualisations we can provide with this new model of data. + +2. **Secured ingest channel** +In this phase, our efforts will focus on securing the communications and integrity of the data. This includes: + - **Generation of the Deployment Hash ID**: + Discussions on whether it should be self-generated and accepted/rejected by the Remote Pulse Service (RPS) or it should be generated and assigned by the RPS because it is the only one that can ensure uniqueness. + - **Locally store the Deployment Hash ID as an encrypted saved object**: + This comes back with a caveat: OSS versions will not be able to receive instructions. We will need to maintain a fallback mechanism to the phase 1 logic (it may be a desired scenario because it could happen the encrypted saved objects are not recoverable due to an error in the deployment and we should still be able to apply that fallback). + - **Authenticity of the information (Local -> Remote)**: + We need to _sign_ the data in some way the RPS can confirm the information reported as for a _Deployment Hash ID_ comes from the right source. + - **Authenticity of the information (Remote -> Local)**: + We need the Local Pulse Service (LPS) to be able to confirm the responses from the RPS data has not been altered by any mediator. It could be done via encryption using a key provided by the LPS. This should be provided to the RPS inside an encrypted payload in the same fashion we currently encrypt the telemetry. + - **Integrity of the data in the channels**: + We need to ensure an external plugin cannot push data to channels to avoid malicious corruption of the data. We could achieve this by either making this plugin only available to Kibana-shipped plugins or storing the `pluginID` that is pushing the data to have better control of the source of the data (then an ingest pipeline can reject any source of data that should not be accepted). + + All the suggestions in this phase can be further discussed at that point (I will create another RFC to discuss those terms after this RFC is approved and merged). + +3. **Instruction handling** +This final phase we'll implement the instruction generation and handling at the same time we are adding more **channels**. +We can discuss at this point if we want to be able to provide _harmless_ instructions for those deployments that are not _secured_ (i.e.: Cloud cost estimations, User-profiled-based marketing updates, ...). + +## Architecture + +As mentioned earlier, at the beginning of this chapter, there are two main components in this architecture: + +1. Remote Pulse Service +2. Local Pulse Service + +### 1. Remote Pulse Service + +This is the service that will receive and store the telemetry from all the _opted-in_ deployments. It will also generate the messages we want to report back to each deployment (aka: instructions). + +#### Deployment + +- The service will be hosted by Elastic. +- Most likely maintained by the Infra team. +- GCP is contemplated at this moment, but we need to confirm how would it affect us regarding the FedRamp approvals (and similar). +- Exposes an API (check [Endpoints](#endpoints) to know more) to inject the data and retrieve the _instructions_. +- The data will be stored in an ES cluster. + +#### Endpoints + +The following endpoints **will send every payload** detailed in below **encrypted** with a similar mechanism to the current telemetry encryption. + +##### Authenticate + +This Endpoint will be used to retrieve a randomised `deploymentID` and a `token` for the cluster to use in all the subsequent requests. Ideally, it will provide some sort of identifier (like `cluster_uuid` or `license.uuid`) so we can revoke its access to any of the endpoints if explicitly requested ([Blocking telemetry input](https://github.com/elastic/telemetry/pull/221) and [Delete previous telemetry data](https://github.com/elastic/telemetry/issues/209)). + +I'd appreciate some insights here to come up with a strong handshake mechanism to avoid stealing identities. + +In order to _dereference_ the data, we can store these mappings in a Vault or Secrets provider instead of an index in our ES. + +_NB: Not for phase 1_ + +##### Opt-In|Out + +Similar to the current telemetry, we want to keep track of when the user opts in or out of telemetry. The implementation can be very similar to the current one. But we recently learned we need to add the origin to know what application has telemetry disabled (Kibana, Beats, Enterprise Search, ...). This makes me wonder if we will ever want to provide a granular option for the user to be able to cherry-pick about what channels are sent and which ones should be disabled. + +##### Inject telemetry + +In order to minimise the amount of requests, this `POST` should accept bulks of data in the payload (mind the payload size limits if any). It will require authentication based on the `deploymentID` and `token` explained in the [previous endpoint](#authenticate) (_NB: Not for phase 1_). + +The received payload will be pushed to a streaming technology (AWS Firehose, Google Pub/Sub, ...). This way we can maintain a buffer in cases the ingestion of data spikes or we need to stop our ES cluster for any maintenance purposes. + +A subscriber to that stream will receive that info a split the payload into smaller documents per channel and index them into their separate indices. + +This indexing should also trigger some additional processes like the **generation of instructions** and _special views_ (only if needed, check the point [Access control](#access-control) for more details). + +_NB: We might want to consider some sort of piggy-backing to include the instructions in the response. But for the purpose of this RFC, scalability and separation of concerns, I'd rather keep it for future possible improvements._ + +##### Retrieve instructions + +_NB: Only after phase 3_ + +This `GET` endpoint should return the list of instructions generated for that deployment. To control the likely ever-growing list of instructions for each deployment, it will accept a `since` query parameter where the requester can specify the timestamp ever since it was to retrieve the new values. + +This endpoint will read the `instructions-*` indices, filtering `updated-at` by the `since` query parameter (if provided) and it will return the results, grouping them by channels. + +Additionally, we can consider accepting an additional query parameter to retrieve only specific channels. For use cases like distributed components (endpoint, apm, beats, ...) polling instructions themselves. + +#### Data model + +The storage of each of the documents, will be based on monthly-rolling indices split by channels. This means we'll have indices like `pulse-raw-{CHANNEL_NAME}-YYYY.MM` and `pulse-instructions-{CHANNEL_NAME}-YYYY.MM` (final names TBD). + +The first group will be used to index all the incoming documents from the telemetry. While the second one will contain the instructions to be sent to the deployments. + +The mapping for those indices will be **`strict`** to avoid anyone storing unwanted/not-allowed info. The indexer defined in [the _Inject telemetry_ endpoint](#inject-telemetry) will need to handle accordingly the errors derived from the strict mapping. +We'll set up a process to add new mappings and their descriptions before every new release. + +#### Access control + +- The access to _raw_ data indices will be very limited. Only granted to those in need of troubleshooting the service and maintaining mappings (this is the Pulse/Telemetry team at the moment). +- Special views (as in aggregations/visualisations/snapshots of the data stored in special indices via separated indexers/aggregators/ES transform or via _BigQuery_ or similar) will be defined for different roles in the company to help them to take informed decisions based on the data. +This way we'll be able to control "who can see what" on a very granual basis. It will also provide us with more flexibility to change to structure of the _raw_ if needed. + +### 2. Local Pulse Service + +This refers to the plugin running in Kibana in each of our customers' deployments. It will be a core service in NP, available for all plugins to get the existing channels, to send pieces of data, and subscribe to instructions. + +The channel handlers are only defined inside the pulse context and are used to normalise the data for each channel before sending it to the remote service. The CODEOWNERS should notify the Pulse team every time there's an intended change in this context. + +#### Data storage + +For the purpose of transparency, we want the user to be able to retrieve the telemetry we send at any point, so we should store the information we send for each channel in their own local _dot_ internal indices (similar to a copy of the `pulse-raw-*` and `pulse-instructions-*` indices in our remote service). We may want to also sync back from the remote service any updates we do to the documents: enrichment of the document, anonymisation, categorisation when it makes sense in that specific channel, ... + +In the same effort, we could even provide some _dashboards_ in Kibana for specific roles in the cluster to understand more about their deployment. + +Only those specific roles (admin?) should have access to these local indices, unless they grant permissions to other users they want to share this information with. + +The users should be able to control how long they want to keep that information for via ILM. A default ILM policy will be setup during the startup if it doesn't exist. + +#### Sending telemetry + +The telemetry will be sent, preferably, from the server. Only falling back to the browser in case we detect the server is behind firewalls and it cannot reach the service or if the user explicitly sets the behaviour in the config. + +Periodically, the process (either in the server or the browser) will retrieve the telemetry to be sent by the channels, compile it into 1 bulk payload and send it encrypted to the [ingest endpoint](#inject-telemetry) explained earlier. + +How often it sends the data, depends on the channel specifications. We will have 3 levels of periodicity: + +- `URGENT`: The data is sent as soon as possible. +- `HIGH`: Sent every hour. +- `NORMAL`: Sent every 24 hours. +- `LOW`: Sent every 3 days. + +Some throttling policy should be applied to avoid exploiting the exceeded use of `URGENT`. + +#### Instruction polling + +Similarly to the sending of the telemetry, the instruction polling should happen only on one end (either the server or the browser). It will store the responses in the local index for each channel and the plugins reacting to those instructions will be able to consume that information based on their own needs (either load only the new ones or all the historic data at once). + +Depending on the subscriptions to the channels by the plugins, the polling will happen with different periodicity, similar to the one described in the chapter above. + +#### Exposing channels to the plugins + +The plugins will be able to send messages and/or consume instructions for any channel by using the methods provided as part of the `coreContext` in the `setup` and `start` lifecycle methods in a fashion like (types to be properly defined when implementing it): + +```typescript +const coreContext: CoreSetup | CoreStart = { + ...existingCoreContext, + pulse: { + sendToChannel: async (channelName: keyof Channels, payload: Channels[channelName]) => void, + instructionsFromChannel$: (channelName: keyof Channels) => Observable, + }, +} +``` + +Plugins will simply need to call `core.pulse.sendToChannel('errors', myUnexpectedErrorIWantToReport)` whenever they want to report any new data to that channel. This will call the channel's handler to store the data. + +Similarly, they'll be able to subscribe to channels like: + +```typescript +core.pulse.instructionsFromChannel$('ui_behaviour_tracking') + .pipe(filterInstructionsForMyPlugin) // Initially, we won't filter the instructions based on the plugin ID (might not be necessary in all cases) + .subscribe(changeTheOrderOfTheComponents); +``` + +Internally in those methods we should append the `pluginId` to know who is sending/receiving the info. + +##### The _legacy_ collection + +The current telemetry collection via the `UsageCollector` service will be maintained until all the current telemetry is fully migrated into their own channels. In the meantime, the current existing telemetry will be sent to Pulse as the `legacy` channel. This way we can maintain the same architecture for the old and new telemetry to come. At this stage, there is no need for any plugin to update their logic unless they want to send more granular data using other (even specific to that plugin) channels. + +The mapping for this `legacy` channel will be kept `dynamic: false` instead of `strict` to ensure compatibility. + +# Drawbacks + +- Pushing data into telemetry nowadays is as simple as implementing your own `usageCollector`. For consuming, though, the telemetry team needs to update the mappings. But as soon as they do so, the previous data is available. Now we'll be more strict about the mapping. Rejecting any data that does not comply. Changing the structure of the reported data will result in data loss in that channel. +- Hard dependency on the Pulse team's availability to update the metrics and on the Infra team to deploy the instruction handlers. +- Testing architecture: any dockerised way to test the local dev environment? +- We'll increase the local usage of indices. Making it more expensive to users to maintain the cluster. We need be to careful with this! Although it might not change much, compared to the current implementation, if any plugin decides to maintain its own index/saved objects to do aggregations afterwards. Similarly, more granularity per channel, may involve more network usage. +- It is indeed a breaking change, but it can be migrated over-time as new features, making use of the instructions. +- We need to update other products already reporting telemetry from outside Kibana (like Beats, Enterprise Search, Logstash, ...) to use the new way of pushing telemetry. + +# Alternatives + +> What other designs have been considered? + +We currently have the newsfeed to be able to communicate to the user. This is actually pulling in Kibana from a public API to retrieve the list of entries to be shown in the notification bar. But this is only limitted to notifications to the user while the new _intructions_ can provide capabilities like self-update/self-configuration of components like endpoints, elasticsearch, ... + +> What is the impact of not doing this? + +Users might not see any benefit from providing telemetry and will opt-out. The quality of the telemetry will likely not be as good (or it will require a higher effort on the plugin end to provide it like in [the latest lens effort](https://github.com/elastic/kibana/issues/46599#issuecomment-545024137)) + +# Adoption strategy + +Initially, we'll focus on the remote service and move the current telemetry to report as a `"legacy"` channel to the new Pulse service. + +Then, we'll focus on doing the client side, providing new APIs to report the data, aiming for the minimum changes on the public end. For instance, the current usage collectors already report an ID, we can work on those IDs mapping to a channel (only grouping them when it makes sense). Nevertheless, it will require the devs to engage with the Pulse team for the mappings and definitions to be properly set up and updated. And any views to be added. + +Finally, the instruction handling APIs are completely new and it will require development on both _remote_ and _local_ ends for the instruction generation and handling. + +# How we teach this + +> What names and terminology work best for these concepts and why? How is this +idea best presented? As a continuation of existing Kibana patterns? + +We have 3 points of view to show here: + +- From the users perspective, we need to show the value for them to have the telemetry activated. +- From the devs, how to generate data and consume instructions. +- From the PMs, how to consume the views + definitions of the fields. + +> Would the acceptance of this proposal mean the Kibana documentation must be +re-organized or altered? Does it change how Kibana is taught to new developers +at any level? + +This telemetry is supposed to be internal only. Only internal developers will be able to add to this. So the documentation will only be for internal puposes. As mentioned in the _Adoption strategy_, the idea is that the devs to report new data to telemetry will need to engage with the Pulse team. + +> How should this feature be taught to existing Kibana developers? + +# Unresolved questions + +- Pending to define a proper handshake in the authentication mechanism to reduce the chance of a man-in-the-middle attack or DDoS. => We already have some ideas thanks to @jportner and @kobelb but it will be resolved during the _Phase 2_ design. +- Opt-in/out per channel? diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 9187e207ed0d60..5ced7bee10b7d4 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -28,11 +28,7 @@ export function plugin() { /** @public types */ export { DataSetup, DataStart } from './plugin'; -export { - SavedQueryAttributes, - SavedQuery, - SavedQueryTimeFilter, -} from '../../../../plugins/data/public'; +export { SavedQuery, SavedQueryTimeFilter } from '../../../../plugins/data/public'; export { // agg_types AggParam, // only the type is used externally, only in vis editor diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts index 18230646ab412d..f40cda8760bc7a 100644 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -18,12 +18,7 @@ */ import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import { - DataPublicPluginStart, - addSearchStrategy, - defaultSearchStrategy, - DataPublicPluginSetup, -} from '../../../../plugins/data/public'; +import { DataPublicPluginStart, DataPublicPluginSetup } from '../../../../plugins/data/public'; import { ExpressionsSetup } from '../../../../plugins/expressions/public'; import { @@ -111,9 +106,6 @@ export class DataPlugin public setup(core: CoreSetup, { data, uiActions }: DataPluginSetupDependencies) { setInjectedMetadata(core.injectedMetadata); - // This is to be deprecated once we switch to the new search service fully - addSearchStrategy(defaultSearchStrategy); - uiActions.attachAction( SELECT_RANGE_TRIGGER, selectRangeAction(data.query.filterManager, data.query.timefilter.timefilter) diff --git a/src/legacy/core_plugins/input_control_vis/public/control/create_search_source.ts b/src/legacy/core_plugins/input_control_vis/public/control/create_search_source.ts index f7927962307574..f238a2287ecdbf 100644 --- a/src/legacy/core_plugins/input_control_vis/public/control/create_search_source.ts +++ b/src/legacy/core_plugins/input_control_vis/public/control/create_search_source.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PhraseFilter, IndexPattern, TimefilterSetup } from '../../../../../plugins/data/public'; +import { PhraseFilter, IndexPattern, TimefilterContract } from '../../../../../plugins/data/public'; import { SearchSource as SearchSourceClass, SearchSourceFields } from '../legacy_imports'; export function createSearchSource( @@ -27,7 +27,7 @@ export function createSearchSource( aggs: any, useTimeFilter: boolean, filters: PhraseFilter[] = [], - timefilter: TimefilterSetup['timefilter'] + timefilter: TimefilterContract ) { const searchSource = initialState ? new SearchSource(initialState) : new SearchSource(); // Do not not inherit from rootSearchSource to avoid picking up time and globals diff --git a/src/legacy/core_plugins/input_control_vis/public/control/list_control_factory.ts b/src/legacy/core_plugins/input_control_vis/public/control/list_control_factory.ts index 56b42f295ce156..8364c82efecdb5 100644 --- a/src/legacy/core_plugins/input_control_vis/public/control/list_control_factory.ts +++ b/src/legacy/core_plugins/input_control_vis/public/control/list_control_factory.ts @@ -26,7 +26,7 @@ import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; import { createSearchSource } from './create_search_source'; import { ControlParams } from '../editor_utils'; import { InputControlVisDependencies } from '../plugin'; -import { IFieldType, TimefilterSetup } from '../../../../../plugins/data/public'; +import { IFieldType, TimefilterContract } from '../../../../../plugins/data/public'; function getEscapedQuery(query = '') { // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators @@ -74,7 +74,7 @@ const termsAgg = ({ field, size, direction, query }: TermsAggArgs) => { export class ListControl extends Control { private getInjectedVar: InputControlVisDependencies['core']['injectedMetadata']['getInjectedVar']; - private timefilter: TimefilterSetup['timefilter']; + private timefilter: TimefilterContract; abortController?: AbortController; lastAncestorValues: any; diff --git a/src/legacy/core_plugins/input_control_vis/public/control/range_control_factory.ts b/src/legacy/core_plugins/input_control_vis/public/control/range_control_factory.ts index b9191436b59687..d9b43c9dff2012 100644 --- a/src/legacy/core_plugins/input_control_vis/public/control/range_control_factory.ts +++ b/src/legacy/core_plugins/input_control_vis/public/control/range_control_factory.ts @@ -26,7 +26,7 @@ import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { createSearchSource } from './create_search_source'; import { ControlParams } from '../editor_utils'; import { InputControlVisDependencies } from '../plugin'; -import { IFieldType, TimefilterSetup } from '../.../../../../../../plugins/data/public'; +import { IFieldType, TimefilterContract } from '../.../../../../../../plugins/data/public'; const minMaxAgg = (field?: IFieldType) => { const aggBody: any = {}; @@ -52,7 +52,7 @@ const minMaxAgg = (field?: IFieldType) => { }; export class RangeControl extends Control { - timefilter: TimefilterSetup['timefilter']; + timefilter: TimefilterContract; abortController: any; min: any; max: any; diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index 6947d985be4363..8a8b5d8e0e3ea3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -70,7 +70,6 @@ export { IIndexPattern, IndexPattern, indexPatterns, - hasSearchStategyForIndexPattern, IFieldType, SearchSource, ISearchSource, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/index.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/index.js index 1a3922dfc20081..5482258e06564f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/index.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/index.js @@ -19,7 +19,6 @@ import { DiscoverNoResults } from './no_results'; import { DiscoverUninitialized } from './uninitialized'; -import { DiscoverUnsupportedIndexPattern } from './unsupported_index_pattern'; import { DiscoverHistogram } from './histogram'; import { getAngularModule, wrapInI18nContext } from '../../../kibana_services'; @@ -33,8 +32,4 @@ app.directive('discoverUninitialized', reactDirective => reactDirective(wrapInI18nContext(DiscoverUninitialized)) ); -app.directive('discoverUnsupportedIndexPattern', reactDirective => - reactDirective(wrapInI18nContext(DiscoverUnsupportedIndexPattern), ['unsupportedType']) -); - app.directive('discoverHistogram', reactDirective => reactDirective(DiscoverHistogram)); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/unsupported_index_pattern.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/unsupported_index_pattern.js deleted file mode 100644 index 7cf4fd1d141813..00000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/unsupported_index_pattern.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Fragment } from 'react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const DiscoverUnsupportedIndexPattern = ({ unsupportedType }) => { - // This message makes the assumption that X-Pack will support this type, as is the case with - // rollup index patterns. - const message = ( - - ); - - return ( - - - - - - - - - - ); -}; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html index 2d44b12989228a..2334e33deadba9 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html @@ -38,11 +38,6 @@

{{screenTitle}}

- - extends IKibanaSearchResponse { diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 7487f048525bdf..7d739103eb2bbb 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -286,11 +286,7 @@ export { export { ES_SEARCH_STRATEGY, SYNC_SEARCH_STRATEGY, - defaultSearchStrategy, - esSearchStrategyProvider, getEsPreference, - addSearchStrategy, - hasSearchStategyForIndexPattern, getSearchErrorType, ISearchContext, TSearchStrategyProvider, @@ -348,9 +344,7 @@ export { SavedQuery, SavedQueryService, SavedQueryTimeFilter, - SavedQueryAttributes, InputTimeRange, - TimefilterSetup, TimeHistory, TimefilterContract, TimeHistoryContract, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index c41a4ef5314437..48125254ff7494 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -49,11 +49,6 @@ import { UiSettingsParams } from 'src/core/server/types'; import { UnregisterCallback } from 'history'; import { UserProvidedValues } from 'src/core/server/types'; -// Warning: (ae-missing-release-tag) "addSearchStrategy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const addSearchStrategy: (searchStrategy: SearchStrategyProvider) => void; - // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -153,11 +148,6 @@ export interface DataPublicPluginStart { }; } -// Warning: (ae-missing-release-tag) "defaultSearchStrategy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const defaultSearchStrategy: SearchStrategyProvider; - // @public (undocumented) export enum ES_FIELD_TYPES { // (undocumented) @@ -313,11 +303,6 @@ export interface EsQueryConfig { // @public (undocumented) export type EsQuerySortValue = Record; -// Warning: (ae-missing-release-tag) "esSearchStrategyProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const esSearchStrategyProvider: TSearchStrategyProvider; - // Warning: (ae-missing-release-tag) "ExistsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -488,11 +473,6 @@ export function getSearchErrorType({ message }: Pick): " // @public (undocumented) export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined; -// Warning: (ae-missing-release-tag) "hasSearchStategyForIndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const hasSearchStategyForIndexPattern: (indexPattern: IndexPattern) => boolean; - // Warning: (ae-missing-release-tag) "IDataPluginServices" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -517,6 +497,8 @@ export interface IDataPluginServices extends Partial { // // @public (undocumented) export interface IEsSearchRequest extends IKibanaSearchRequest { + // (undocumented) + indexType?: string; // (undocumented) params: SearchParams; } @@ -1227,28 +1209,14 @@ export interface RefreshInterval { // // @public (undocumented) export interface SavedQuery { + // Warning: (ae-forgotten-export) The symbol "SavedQueryAttributes" needs to be exported by the entry point index.d.ts + // // (undocumented) attributes: SavedQueryAttributes; // (undocumented) id: string; } -// Warning: (ae-missing-release-tag) "SavedQueryAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface SavedQueryAttributes { - // (undocumented) - description: string; - // (undocumented) - filters?: Filter[]; - // (undocumented) - query: Query; - // (undocumented) - timefilter?: SavedQueryTimeFilter; - // (undocumented) - title: string; -} - // Warning: (ae-missing-release-tag) "SavedQueryService" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1484,14 +1452,6 @@ export const syncQueryStateWithUrl: (query: Pick<{ // @public (undocumented) export type TimefilterContract = PublicMethodsOf; -// @public (undocumented) -export interface TimefilterSetup { - // (undocumented) - history: TimeHistoryContract; - // (undocumented) - timefilter: TimefilterContract; -} - // Warning: (ae-missing-release-tag) "TimeHistory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts index c983cc4ea8fc58..a86a5b4ed401e3 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts @@ -18,8 +18,8 @@ */ import { createSavedQueryService } from './saved_query_service'; -import { SavedQueryAttributes } from '../..'; import { FilterStateStore } from '../../../common'; +import { SavedQueryAttributes } from './types'; const savedQueryAttributes: SavedQueryAttributes = { title: 'foo', diff --git a/src/plugins/data/public/search/fetch/call_client.test.ts b/src/plugins/data/public/search/fetch/call_client.test.ts index 6b43157aab83b1..7a99b7c064515b 100644 --- a/src/plugins/data/public/search/fetch/call_client.test.ts +++ b/src/plugins/data/public/search/fetch/call_client.test.ts @@ -20,60 +20,35 @@ import { callClient } from './call_client'; import { handleResponse } from './handle_response'; import { FetchHandlers } from './types'; -import { SearchRequest } from '../..'; -import { SearchStrategySearchParams } from '../search_strategy'; - -const mockResponses = [{}, {}]; -const mockAbortFns = [jest.fn(), jest.fn()]; -const mockSearchFns = [ - jest.fn(({ searchRequests }: SearchStrategySearchParams) => ({ - searching: Promise.resolve(Array(searchRequests.length).fill(mockResponses[0])), - abort: mockAbortFns[0], - })), - jest.fn(({ searchRequests }: SearchStrategySearchParams) => ({ - searching: Promise.resolve(Array(searchRequests.length).fill(mockResponses[1])), - abort: mockAbortFns[1], - })), -]; -const mockSearchStrategies = mockSearchFns.map((search, i) => ({ search, id: i })); +import { SearchStrategySearchParams, defaultSearchStrategy } from '../search_strategy'; +const mockAbortFn = jest.fn(); jest.mock('./handle_response', () => ({ handleResponse: jest.fn((request, response) => response), })); -jest.mock('../search_strategy', () => ({ - getSearchStrategyForSearchRequest: (request: SearchRequest) => - mockSearchStrategies[request._searchStrategyId], - getSearchStrategyById: (id: number) => mockSearchStrategies[id], -})); +jest.mock('../search_strategy', () => { + return { + defaultSearchStrategy: { + search: jest.fn(({ searchRequests }: SearchStrategySearchParams) => { + return { + searching: Promise.resolve( + searchRequests.map(req => { + return { + id: req._searchStrategyId, + }; + }) + ), + abort: mockAbortFn, + }; + }), + }, + }; +}); describe('callClient', () => { beforeEach(() => { (handleResponse as jest.Mock).mockClear(); - mockAbortFns.forEach(fn => fn.mockClear()); - mockSearchFns.forEach(fn => fn.mockClear()); - }); - - test('Executes each search strategy with its group of matching requests', () => { - const searchRequests = [ - { _searchStrategyId: 0 }, - { _searchStrategyId: 1 }, - { _searchStrategyId: 0 }, - { _searchStrategyId: 1 }, - ]; - - callClient(searchRequests, [], {} as FetchHandlers); - - expect(mockSearchFns[0]).toBeCalled(); - expect(mockSearchFns[0].mock.calls[0][0].searchRequests).toEqual([ - searchRequests[0], - searchRequests[2], - ]); - expect(mockSearchFns[1]).toBeCalled(); - expect(mockSearchFns[1].mock.calls[0][0].searchRequests).toEqual([ - searchRequests[1], - searchRequests[3], - ]); }); test('Passes the additional arguments it is given to the search strategy', () => { @@ -82,8 +57,11 @@ describe('callClient', () => { callClient(searchRequests, [], args); - expect(mockSearchFns[0]).toBeCalled(); - expect(mockSearchFns[0].mock.calls[0][0]).toEqual({ searchRequests, ...args }); + expect(defaultSearchStrategy.search).toBeCalled(); + expect((defaultSearchStrategy.search as any).mock.calls[0][0]).toEqual({ + searchRequests, + ...args, + }); }); test('Returns the responses in the original order', async () => { @@ -91,7 +69,8 @@ describe('callClient', () => { const responses = await Promise.all(callClient(searchRequests, [], {} as FetchHandlers)); - expect(responses).toEqual([mockResponses[1], mockResponses[0]]); + expect(responses[0]).toEqual({ id: searchRequests[0]._searchStrategyId }); + expect(responses[1]).toEqual({ id: searchRequests[1]._searchStrategyId }); }); test('Calls handleResponse with each request and response', async () => { @@ -101,8 +80,12 @@ describe('callClient', () => { await Promise.all(responses); expect(handleResponse).toBeCalledTimes(2); - expect(handleResponse).toBeCalledWith(searchRequests[0], mockResponses[0]); - expect(handleResponse).toBeCalledWith(searchRequests[1], mockResponses[1]); + expect(handleResponse).toBeCalledWith(searchRequests[0], { + id: searchRequests[0]._searchStrategyId, + }); + expect(handleResponse).toBeCalledWith(searchRequests[1], { + id: searchRequests[1]._searchStrategyId, + }); }); test('If passed an abortSignal, calls abort on the strategy if the signal is aborted', () => { @@ -117,7 +100,7 @@ describe('callClient', () => { callClient(searchRequests, requestOptions, {} as FetchHandlers); abortController.abort(); - expect(mockAbortFns[0]).toBeCalled(); - expect(mockAbortFns[1]).not.toBeCalled(); + expect(mockAbortFn).toBeCalled(); + // expect(mockAbortFns[1]).not.toBeCalled(); }); }); diff --git a/src/plugins/data/public/search/fetch/call_client.ts b/src/plugins/data/public/search/fetch/call_client.ts index 6cc58b05ea183b..b3c4c682fa60c0 100644 --- a/src/plugins/data/public/search/fetch/call_client.ts +++ b/src/plugins/data/public/search/fetch/call_client.ts @@ -17,10 +17,9 @@ * under the License. */ -import { groupBy } from 'lodash'; import { handleResponse } from './handle_response'; import { FetchOptions, FetchHandlers } from './types'; -import { getSearchStrategyForSearchRequest, getSearchStrategyById } from '../search_strategy'; +import { defaultSearchStrategy } from '../search_strategy'; import { SearchRequest } from '..'; export function callClient( @@ -34,34 +33,18 @@ export function callClient( FetchOptions ]> = searchRequests.map((request, i) => [request, requestsOptions[i]]); const requestOptionsMap = new Map(requestOptionEntries); - - // Group the requests by the strategy used to search that specific request - const searchStrategyMap = groupBy(searchRequests, (request, i) => { - const searchStrategy = getSearchStrategyForSearchRequest(request, requestsOptions[i]); - return searchStrategy.id; - }); - - // Execute each search strategy with the group of requests, but return the responses in the same - // order in which they were received. We use a map to correlate the original request with its - // response. const requestResponseMap = new Map(); - Object.keys(searchStrategyMap).forEach(searchStrategyId => { - const searchStrategy = getSearchStrategyById(searchStrategyId); - const requests = searchStrategyMap[searchStrategyId]; - // There's no way `searchStrategy` could be undefined here because if we didn't get a matching strategy for this ID - // then an error would have been thrown above - const { searching, abort } = searchStrategy!.search({ - searchRequests: requests, - ...fetchHandlers, - }); + const { searching, abort } = defaultSearchStrategy.search({ + searchRequests, + ...fetchHandlers, + }); - requests.forEach((request, i) => { - const response = searching.then(results => handleResponse(request, results[i])); - const { abortSignal = null } = requestOptionsMap.get(request) || {}; - if (abortSignal) abortSignal.addEventListener('abort', abort); - requestResponseMap.set(request, response); - }); - }, []); + searchRequests.forEach((request, i) => { + const response = searching.then(results => handleResponse(request, results[i])); + const { abortSignal = null } = requestOptionsMap.get(request) || {}; + if (abortSignal) abortSignal.addEventListener('abort', abort); + requestResponseMap.set(request, response); + }); return searchRequests.map(request => requestResponseMap.get(request)); } diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 2a54cfe2be7857..6ccd90c6a9eff9 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -42,14 +42,7 @@ export { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search export { LegacyApiCaller, SearchRequest, SearchResponse } from './es_client'; -export { - addSearchStrategy, - hasSearchStategyForIndexPattern, - defaultSearchStrategy, - SearchError, - SearchStrategyProvider, - getSearchErrorType, -} from './search_strategy'; +export { SearchError, SearchStrategyProvider, getSearchErrorType } from './search_strategy'; export { ISearchSource, diff --git a/src/plugins/data/public/search/search_source/search_source.ts b/src/plugins/data/public/search/search_source/search_source.ts index 21e5ded6983acb..0c3321f03dabc5 100644 --- a/src/plugins/data/public/search/search_source/search_source.ts +++ b/src/plugins/data/public/search/search_source/search_source.ts @@ -73,7 +73,7 @@ import _ from 'lodash'; import { normalizeSortRequest } from './normalize_sort_request'; import { filterDocvalueFields } from './filter_docvalue_fields'; import { fieldWildcardFilter } from '../../../../kibana_utils/public'; -import { SearchRequest } from '../..'; +import { IIndexPattern, SearchRequest } from '../..'; import { SearchSourceOptions, SearchSourceFields } from './types'; import { fetchSoon, FetchOptions, RequestFailure } from '../fetch'; @@ -339,11 +339,20 @@ export class SearchSource { return searchRequest; } + private getIndexType(index: IIndexPattern) { + if (this.searchStrategyId) { + return this.searchStrategyId === 'default' ? undefined : this.searchStrategyId; + } else { + return index?.type; + } + } + private flatten() { const searchRequest = this.mergeProps(); searchRequest.body = searchRequest.body || {}; const { body, index, fields, query, filters, highlightAll } = searchRequest; + searchRequest.indexType = this.getIndexType(index); const computedFields = index ? index.getComputedFields() : {}; diff --git a/src/plugins/data/public/search/search_strategy/default_search_strategy.ts b/src/plugins/data/public/search/search_strategy/default_search_strategy.ts index 6fcb1e6b3e8d27..2bd88f51587a8a 100644 --- a/src/plugins/data/public/search/search_strategy/default_search_strategy.ts +++ b/src/plugins/data/public/search/search_strategy/default_search_strategy.ts @@ -74,7 +74,7 @@ function search({ }: SearchStrategySearchParams) { const abortController = new AbortController(); const searchParams = getSearchParams(config, esShardTimeout); - const promises = searchRequests.map(({ index, body }) => { + const promises = searchRequests.map(({ index, indexType, body }) => { const params = { index: index.title || index, body, @@ -82,7 +82,7 @@ function search({ }; const { signal } = abortController; return searchService - .search({ params }, { signal }) + .search({ params, indexType }, { signal }) .toPromise() .then(({ rawResponse }) => rawResponse); }); diff --git a/src/plugins/data/public/search/search_strategy/index.ts b/src/plugins/data/public/search/search_strategy/index.ts index 330e10d7d30e4d..e3de2ea46e3eca 100644 --- a/src/plugins/data/public/search/search_strategy/index.ts +++ b/src/plugins/data/public/search/search_strategy/index.ts @@ -17,13 +17,6 @@ * under the License. */ -export { - addSearchStrategy, - hasSearchStategyForIndexPattern, - getSearchStrategyById, - getSearchStrategyForSearchRequest, -} from './search_strategy_registry'; - export { SearchError, getSearchErrorType } from './search_error'; export { SearchStrategyProvider, SearchStrategySearchParams } from './types'; diff --git a/src/plugins/data/public/search/search_strategy/search_strategy_registry.test.ts b/src/plugins/data/public/search/search_strategy/search_strategy_registry.test.ts deleted file mode 100644 index eaf86e1b270d53..00000000000000 --- a/src/plugins/data/public/search/search_strategy/search_strategy_registry.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { IndexPattern } from '../..'; -import { noOpSearchStrategy } from './no_op_search_strategy'; -import { - searchStrategies, - addSearchStrategy, - getSearchStrategyByViability, - getSearchStrategyById, - getSearchStrategyForSearchRequest, - hasSearchStategyForIndexPattern, -} from './search_strategy_registry'; -import { SearchStrategyProvider } from './types'; - -const mockSearchStrategies: SearchStrategyProvider[] = [ - { - id: '0', - isViable: (index: IndexPattern) => index.id === '0', - search: () => ({ - searching: Promise.resolve([]), - abort: () => void 0, - }), - }, - { - id: '1', - isViable: (index: IndexPattern) => index.id === '1', - search: () => ({ - searching: Promise.resolve([]), - abort: () => void 0, - }), - }, -]; - -describe('Search strategy registry', () => { - beforeEach(() => { - searchStrategies.length = 0; - }); - - describe('addSearchStrategy', () => { - it('adds a search strategy', () => { - addSearchStrategy(mockSearchStrategies[0]); - expect(searchStrategies.length).toBe(1); - }); - - it('does not add a search strategy if it is already included', () => { - addSearchStrategy(mockSearchStrategies[0]); - addSearchStrategy(mockSearchStrategies[0]); - expect(searchStrategies.length).toBe(1); - }); - }); - - describe('getSearchStrategyByViability', () => { - beforeEach(() => { - mockSearchStrategies.forEach(addSearchStrategy); - }); - - it('returns the viable strategy', () => { - expect(getSearchStrategyByViability({ id: '0' } as IndexPattern)).toBe( - mockSearchStrategies[0] - ); - expect(getSearchStrategyByViability({ id: '1' } as IndexPattern)).toBe( - mockSearchStrategies[1] - ); - }); - - it('returns undefined if there is no viable strategy', () => { - expect(getSearchStrategyByViability({ id: '-1' } as IndexPattern)).toBe(undefined); - }); - }); - - describe('getSearchStrategyById', () => { - beforeEach(() => { - mockSearchStrategies.forEach(addSearchStrategy); - }); - - it('returns the strategy by ID', () => { - expect(getSearchStrategyById('0')).toBe(mockSearchStrategies[0]); - expect(getSearchStrategyById('1')).toBe(mockSearchStrategies[1]); - }); - - it('returns undefined if there is no strategy with that ID', () => { - expect(getSearchStrategyById('-1')).toBe(undefined); - }); - - it('returns the noOp search strategy if passed that ID', () => { - expect(getSearchStrategyById('noOp')).toBe(noOpSearchStrategy); - }); - }); - - describe('getSearchStrategyForSearchRequest', () => { - beforeEach(() => { - mockSearchStrategies.forEach(addSearchStrategy); - }); - - it('returns the strategy by ID if provided', () => { - expect(getSearchStrategyForSearchRequest({}, { searchStrategyId: '1' })).toBe( - mockSearchStrategies[1] - ); - }); - - it('throws if there is no strategy by provided ID', () => { - expect(() => - getSearchStrategyForSearchRequest({}, { searchStrategyId: '-1' }) - ).toThrowErrorMatchingInlineSnapshot(`"No strategy with ID -1"`); - }); - - it('returns the strategy by viability if there is one', () => { - expect( - getSearchStrategyForSearchRequest({ - index: { - id: '1', - }, - }) - ).toBe(mockSearchStrategies[1]); - }); - - it('returns the no op strategy if there is no viable strategy', () => { - expect(getSearchStrategyForSearchRequest({ index: '3' })).toBe(noOpSearchStrategy); - }); - }); - - describe('hasSearchStategyForIndexPattern', () => { - beforeEach(() => { - mockSearchStrategies.forEach(addSearchStrategy); - }); - - it('returns whether there is a search strategy for this index pattern', () => { - expect(hasSearchStategyForIndexPattern({ id: '0' } as IndexPattern)).toBe(true); - expect(hasSearchStategyForIndexPattern({ id: '-1' } as IndexPattern)).toBe(false); - }); - }); -}); diff --git a/src/plugins/data/public/search/search_strategy/search_strategy_registry.ts b/src/plugins/data/public/search/search_strategy/search_strategy_registry.ts deleted file mode 100644 index 1ab6f7d4e1effc..00000000000000 --- a/src/plugins/data/public/search/search_strategy/search_strategy_registry.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { IndexPattern } from '../..'; -import { SearchStrategyProvider } from './types'; -import { noOpSearchStrategy } from './no_op_search_strategy'; -import { SearchResponse } from '..'; - -export const searchStrategies: SearchStrategyProvider[] = []; - -export const addSearchStrategy = (searchStrategy: SearchStrategyProvider) => { - if (searchStrategies.includes(searchStrategy)) { - return; - } - - searchStrategies.push(searchStrategy); -}; - -export const getSearchStrategyByViability = (indexPattern: IndexPattern) => { - return searchStrategies.find(searchStrategy => { - return searchStrategy.isViable(indexPattern); - }); -}; - -export const getSearchStrategyById = (searchStrategyId: string) => { - return [...searchStrategies, noOpSearchStrategy].find(searchStrategy => { - return searchStrategy.id === searchStrategyId; - }); -}; - -export const getSearchStrategyForSearchRequest = ( - searchRequest: SearchResponse, - { searchStrategyId }: { searchStrategyId?: string } = {} -) => { - // Allow the searchSource to declare the correct strategy with which to execute its searches. - if (searchStrategyId != null) { - const strategy = getSearchStrategyById(searchStrategyId); - if (!strategy) throw Error(`No strategy with ID ${searchStrategyId}`); - return strategy; - } - - // Otherwise try to match it to a strategy. - const viableSearchStrategy = getSearchStrategyByViability(searchRequest.index); - - if (viableSearchStrategy) { - return viableSearchStrategy; - } - - // This search strategy automatically rejects with an error. - return noOpSearchStrategy; -}; - -export const hasSearchStategyForIndexPattern = (indexPattern: IndexPattern) => { - return Boolean(getSearchStrategyByViability(indexPattern)); -}; diff --git a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx index 7183f14bdb2555..36dcd4a00c05eb 100644 --- a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx +++ b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx @@ -35,7 +35,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { sortBy, isEqual } from 'lodash'; -import { SavedQuery, SavedQueryAttributes, SavedQueryService } from '../..'; +import { SavedQuery, SavedQueryService } from '../..'; +import { SavedQueryAttributes } from '../../query'; interface Props { savedQuery?: SavedQueryAttributes; 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 26055a3ae41f71..b4ee02eefaf848 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 @@ -31,6 +31,13 @@ export const esSearchStrategyProvider: TSearchStrategyProvider { const config = await context.config$.pipe(first()).toPromise(); const defaultParams = getDefaultSearchParams(config); + + // Only default index pattern type is supported here. + // See data_enhanced for other type support. + if (!!request.indexType) { + throw new Error(`Unsupported index pattern type ${request.indexType}`); + } + const params = { ...defaultParams, ...request.params, diff --git a/src/plugins/data/server/search/routes.ts b/src/plugins/data/server/search/routes.ts index 2b8c4b95ee0228..e618f99084aed2 100644 --- a/src/plugins/data/server/search/routes.ts +++ b/src/plugins/data/server/search/routes.ts @@ -43,11 +43,11 @@ export function registerSearchRoute(router: IRouter): void { return res.ok({ body: response }); } catch (err) { return res.customError({ - statusCode: err.statusCode, + statusCode: err.statusCode || 500, body: { message: err.message, attributes: { - error: err.body.error, + error: err.body?.error || err.message, }, }, }); diff --git a/src/plugins/visualizations/public/persisted_state/persisted_state.ts b/src/plugins/visualizations/public/persisted_state/persisted_state.ts index d09dcd5381511e..b81b651c735093 100644 --- a/src/plugins/visualizations/public/persisted_state/persisted_state.ts +++ b/src/plugins/visualizations/public/persisted_state/persisted_state.ts @@ -26,10 +26,8 @@ function prepSetParams(key: PersistedStateKey, value: any, path: PersistedStateP // key must be the value, set the entire state using it if (value === undefined && (isPlainObject(key) || path.length > 0)) { // setting entire tree, swap the key and value to write to the state - return { - value: key, - key: undefined, - }; + value = key; + key = undefined; } // ensure the value being passed in is never mutated diff --git a/x-pack/index.js b/x-pack/index.js index 893802ea816213..c917befb4b3ddb 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -31,7 +31,6 @@ import { crossClusterReplication } from './legacy/plugins/cross_cluster_replicat import { upgradeAssistant } from './legacy/plugins/upgrade_assistant'; import { uptime } from './legacy/plugins/uptime'; import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; -import { transform } from './legacy/plugins/transform'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; import { lens } from './legacy/plugins/lens'; @@ -61,7 +60,6 @@ module.exports = function(kibana) { infra(kibana), taskManager(kibana), rollup(kibana), - transform(kibana), siem(kibana), remoteClusters(kibana), crossClusterReplication(kibana), diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx index c744c357c9550d..983375ecd4f61b 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx @@ -610,7 +610,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.excludedFieldsHelpText', { defaultMessage: - 'Optionally select fields to be excluded from analysis. All other supported fields will be included', + 'Select fields to exclude from analysis. All other supported fields are included.', })} error={ excludesOptions.length === 0 && diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx index 988daac528fd77..ffed1ebf522f40 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx @@ -21,7 +21,7 @@ export const JobType: FC = ({ type, setFormState }) => { 'xpack.ml.dataframe.analytics.create.outlierDetectionHelpText', { defaultMessage: - 'Outlier detection jobs require a source index that is mapped as a table-like data structure and will only analyze numeric and boolean fields. Please use the advanced editor to add custom options to the configuration.', + 'Outlier detection jobs require a source index that is mapped as a table-like data structure and analyze only numeric and boolean fields. Use the advanced editor to add custom options to the configuration.', } ); @@ -29,7 +29,7 @@ export const JobType: FC = ({ type, setFormState }) => { 'xpack.ml.dataframe.analytics.create.outlierRegressionHelpText', { defaultMessage: - 'Regression jobs will only analyze numeric fields. Please use the advanced editor to apply custom options such as the prediction field name.', + 'Regression jobs analyze only numeric fields. Use the advanced editor to apply custom options, such as the prediction field name.', } ); @@ -37,7 +37,7 @@ export const JobType: FC = ({ type, setFormState }) => { 'xpack.ml.dataframe.analytics.create.classificationHelpText', { defaultMessage: - 'Classification jobs require a source index that is mapped as a table-like data structure and supports fields that are numeric, boolean, text, keyword or ip. Please use the advanced editor to apply custom options such as the prediction field name.', + 'Classification jobs require a source index that is mapped as a table-like data structure and support fields that are numeric, boolean, text, keyword, or ip. Use the advanced editor to apply custom options, such as the prediction field name.', } ); diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js index 688abd1383ecb4..a413dd316b7e32 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js @@ -249,7 +249,7 @@ export const ml = { recognizeIndex(obj) { return http({ - url: `${basePath()}/modules/recognize/${obj.indexPatternTitle}`, + url: `${basePath()}/modules/recognize/${encodeURIComponent(obj.indexPatternTitle)}`, method: 'GET', }); }, @@ -308,7 +308,9 @@ export const ml = { ]); return http({ - url: `${basePath()}/data_visualizer/get_field_stats/${obj.indexPatternTitle}`, + url: `${basePath()}/data_visualizer/get_field_stats/${encodeURIComponent( + obj.indexPatternTitle + )}`, method: 'POST', data, }); @@ -326,7 +328,9 @@ export const ml = { ]); return http({ - url: `${basePath()}/data_visualizer/get_overall_stats/${obj.indexPatternTitle}`, + url: `${basePath()}/data_visualizer/get_overall_stats/${encodeURIComponent( + obj.indexPatternTitle + )}`, method: 'POST', data, }); diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js index 1ac391c7f84ae3..cef2b3d2ffd7f5 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js @@ -127,7 +127,9 @@ export const jobs = { newJobCaps(indexPatternTitle, isRollup = false) { const isRollupString = isRollup === true ? `?rollup=true` : ''; return http({ - url: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}${isRollupString}`, + url: `${basePath()}/jobs/new_job_caps/${encodeURIComponent( + indexPatternTitle + )}${isRollupString}`, method: 'GET', }); }, diff --git a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts index 2a1ffe79d033cd..6982d3d2dba976 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimefilterSetup } from 'src/plugins/data/public'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { IUiSettingsClient, ChromeStart, @@ -23,7 +23,7 @@ import { import { SecurityPluginSetup } from '../../../../../../plugins/security/public'; export interface DependencyCache { - timefilter: TimefilterSetup | null; + timefilter: DataPublicPluginSetup['query']['timefilter'] | null; config: IUiSettingsClient | null; indexPatterns: IndexPatternsContract | null; chrome: ChromeStart | null; diff --git a/x-pack/legacy/plugins/rollup/public/legacy.ts b/x-pack/legacy/plugins/rollup/public/legacy.ts index e3e663ac7b0f44..e137799bd34fea 100644 --- a/x-pack/legacy/plugins/rollup/public/legacy.ts +++ b/x-pack/legacy/plugins/rollup/public/legacy.ts @@ -7,7 +7,6 @@ import { npSetup, npStart } from 'ui/new_platform'; import { aggTypeFilters } from 'ui/agg_types'; import { aggTypeFieldFilters } from 'ui/agg_types'; -import { addSearchStrategy } from '../../../../../src/plugins/data/public'; import { RollupPlugin } from './plugin'; import { setup as management } from '../../../../../src/legacy/core_plugins/management/public/legacy'; @@ -18,7 +17,6 @@ export const setup = plugin.setup(npSetup.core, { __LEGACY: { aggTypeFilters, aggTypeFieldFilters, - addSearchStrategy, managementLegacy: management, }, }); diff --git a/x-pack/legacy/plugins/rollup/public/plugin.ts b/x-pack/legacy/plugins/rollup/public/plugin.ts index a01383f4733ef8..2d2ff4c8449d87 100644 --- a/x-pack/legacy/plugins/rollup/public/plugin.ts +++ b/x-pack/legacy/plugins/rollup/public/plugin.ts @@ -7,14 +7,12 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; import { AggTypeFilters, AggTypeFieldFilters } from './legacy_imports'; -import { SearchStrategyProvider } from '../../../../../src/plugins/data/public'; import { ManagementSetup as ManagementSetupLegacy } from '../../../../../src/legacy/core_plugins/management/public/np_ready'; import { rollupBadgeExtension, rollupToggleExtension } from './extend_index_management'; // @ts-ignore import { RollupIndexPatternCreationConfig } from './index_pattern_creation/rollup_index_pattern_creation_config'; // @ts-ignore import { RollupIndexPatternListConfig } from './index_pattern_list/rollup_index_pattern_list_config'; -import { getRollupSearchStrategy } from './search/rollup_search_strategy'; // @ts-ignore import { initAggTypeFilter } from './visualize/agg_type_filter'; // @ts-ignore @@ -37,7 +35,6 @@ export interface RollupPluginSetupDependencies { __LEGACY: { aggTypeFilters: AggTypeFilters; aggTypeFieldFilters: AggTypeFieldFilters; - addSearchStrategy: (searchStrategy: SearchStrategyProvider) => void; managementLegacy: ManagementSetupLegacy; }; home?: HomePublicPluginSetup; @@ -49,7 +46,7 @@ export class RollupPlugin implements Plugin { setup( core: CoreSetup, { - __LEGACY: { aggTypeFilters, aggTypeFieldFilters, addSearchStrategy, managementLegacy }, + __LEGACY: { aggTypeFilters, aggTypeFieldFilters, managementLegacy }, home, management, indexManagement, @@ -67,7 +64,6 @@ export class RollupPlugin implements Plugin { if (isRollupIndexPatternsEnabled) { managementLegacy.indexPattern.creation.add(RollupIndexPatternCreationConfig); managementLegacy.indexPattern.list.add(RollupIndexPatternListConfig); - addSearchStrategy(getRollupSearchStrategy(core.http.fetch)); initAggTypeFilter(aggTypeFilters); initAggTypeFieldFilter(aggTypeFieldFilters); } diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index c3ca0a16df797a..d217d26e84836b 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -10,8 +10,7 @@ The Kibana actions plugin provides a framework to create executable actions. You - Execute an action, passing it a parameter object. - Perform CRUD operations on actions. ------ - +--- Table of Contents @@ -61,15 +60,18 @@ Table of Contents - [`config`](#config-5) - [`secrets`](#secrets-5) - [`params`](#params-5) + - [ServiceNow](#servicenow) + - [`config`](#config-6) + - [`secrets`](#secrets-6) + - [`params`](#params-6) - [Command Line Utility](#command-line-utility) - ## Terminology -**Action Type**: A programatically defined integration with another service, with an expected set of configuration and parameters properties, typically defined with a schema. Plugins can add new +**Action Type**: A programatically defined integration with another service, with an expected set of configuration and parameters properties, typically defined with a schema. Plugins can add new action types. -**Action**: A configuration object associated with an action type, that is ready to be executed. The configuration is persisted via Saved Objects, and some/none/all of the configuration properties can be stored encrypted. +**Action**: A configuration object associated with an action type, that is ready to be executed. The configuration is persisted via Saved Objects, and some/none/all of the configuration properties can be stored encrypted. ## Usage @@ -78,36 +80,37 @@ action types. 3. Use alerts to execute actions or execute manually (see firing actions). ## Kibana Actions Configuration + Implemented under the [Actions Config](./server/actions_config.ts). ### Configuration Options Built-In-Actions are configured using the _xpack.actions_ namespoace under _kibana.yml_, and have the following configuration options: -| Namespaced Key | Description | Type | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | -| _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | -| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | +| Namespaced Key | Description | Type | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | +| _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | +| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | #### Whitelisting Built-in Action Types + It is worth noting that the **whitelistedHosts** configuation applies to built-in action types (such as Slack, or PagerDuty) as well. Uniquely, the _PagerDuty Action Type_ has been configured to support the service's Events API (at _https://events.pagerduty.com/v2/enqueue_, which you can read about [here](https://v2.developer.pagerduty.com/docs/events-api-v2)) as a default, but this too, must be included in the whitelist before the PagerDuty action can be used. - ### Configuration Utilities This module provides a Utilities for interacting with the configuration. -| Method | Arguments | Description | Return Type | -| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | -| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | -| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | -| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | -| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | -| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | +| Method | Arguments | Description | Return Type | +| ------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | +| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | +| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | +| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | ## Action types @@ -117,38 +120,37 @@ This module provides a Utilities for interacting with the configuration. The following table describes the properties of the `options` object. -|Property|Description|Type| -|---|---|---| -|id|Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types.|string| -|name|A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types.|string| -|unencryptedAttributes|A list of opt-out attributes that don't need to be encrypted. These attributes won't need to be re-entered on import / export when the feature becomes available. These attributes will also be readable / displayed when it comes to a table / edit screen.|array of strings| -|validate.params|When developing an action type, it needs to accept parameters to know what to do with the action. (Example to, from, subject, body of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message|schema / validation function| -|validate.config|Similar to params, a config is required when creating an action (for example host, port, username, and password of an email server). |schema / validation function| -|executor|This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below.|Function| +| Property | Description | Type | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | +| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | +| unencryptedAttributes | A list of opt-out attributes that don't need to be encrypted. These attributes won't need to be re-entered on import / export when the feature becomes available. These attributes will also be readable / displayed when it comes to a table / edit screen. | array of strings | +| validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example to, from, subject, body of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function | +| validate.config | Similar to params, a config is required when creating an action (for example host, port, username, and password of an email server). | schema / validation function | +| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | -**Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. +**Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. ### Executor -This is the primary function for an action type. Whenever the action needs to execute, this function will perform the action. It receives a variety of parameters. The following table describes the properties that the executor receives. +This is the primary function for an action type. Whenever the action needs to execute, this function will perform the action. It receives a variety of parameters. The following table describes the properties that the executor receives. **executor(options)** -|Property|Description| -|---|---| -|actionId|The action saved object id that the action type is executing for.| -|config|The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type.| -|params|Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function.| -|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| -|services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled).| -|services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| +| Property | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| actionId | The action saved object id that the action type is executing for. | +| config | The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | +| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | +| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR. | +| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | +| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | ### Example -The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: +The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: [x-pack/plugins/actions/server/builtin_action_types/email.ts](server/builtin_action_types/email.ts) - ## RESTful API Using an action type requires an action to be created that will contain and encrypt configuration for a given action type. See below for CRUD operations using the API. @@ -157,20 +159,20 @@ Using an action type requires an action to be created that will contain and encr Payload: -|Property|Description|Type| -|---|---|---| -|name|A name to reference and search in the future. This value will be used to populate dropdowns.|string| -|actionTypeId|The id value of the action type you want to call when the action executes.|string| -|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| -|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| +| Property | Description | Type | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| name | A name to reference and search in the future. This value will be used to populate dropdowns. | string | +| actionTypeId | The id value of the action type you want to call when the action executes. | string | +| config | The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined. | object | +| secrets | The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined. | object | ### `DELETE /api/action/{id}`: Delete action Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to delete.|string| +| Property | Description | Type | +| -------- | --------------------------------------------- | ------ | +| id | The id of the action you're trying to delete. | string | ### `GET /api/action/_find`: Find actions @@ -182,9 +184,9 @@ See the [saved objects API documentation for find](https://www.elastic.co/guide/ Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to get.|string| +| Property | Description | Type | +| -------- | ------------------------------------------ | ------ | +| id | The id of the action you're trying to get. | string | ### `GET /api/action/types`: List action types @@ -194,31 +196,31 @@ No parameters. Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to update.|string| +| Property | Description | Type | +| -------- | --------------------------------------------- | ------ | +| id | The id of the action you're trying to update. | string | Payload: -|Property|Description|Type| -|---|---|---| -|name|A name to reference and search in the future. This value will be used to populate dropdowns.|string| -|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| -|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| +| Property | Description | Type | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| name | A name to reference and search in the future. This value will be used to populate dropdowns. | string | +| config | The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined. | object | +| secrets | The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined. | object | ### `POST /api/action/{id}/_execute`: Execute action Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to execute.|string| +| Property | Description | Type | +| -------- | ---------------------------------------------- | ------ | +| id | The id of the action you're trying to execute. | string | Payload: -|Property|Description|Type| -|---|---|---| -|params|The parameters the action type requires for the execution.|object| +| Property | Description | Type | +| -------- | ---------------------------------------------------------- | ------ | +| params | The parameters the action type requires for the execution. | object | ## Firing actions @@ -228,12 +230,12 @@ The plugin exposes an execute function that you can use to run actions. The following table describes the properties of the `options` object. -|Property|Description|Type| -|---|---|---| -|id|The id of the action you want to execute.|string| -|params|The `params` value to give the action type executor.|object| -|spaceId|The space id the action is within.|string| -|apiKey|The Elasticsearch API key to use for context. (Note: only required and used when security is enabled).|string| +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------ | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | +| spaceId | The space id the action is within. | string | +| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string | ## Example @@ -256,23 +258,25 @@ server.plugins.actions.execute({ Kibana ships with a set of built-in action types: -|Type|Id|Description| -|---|---|---| -|[Server log](#server-log)|`.log`|Logs messages to the Kibana log using `server.log()`| -|[Email](#email)|`.email`|Sends an email using SMTP| -|[Slack](#slack)|`.slack`|Posts a message to a slack channel| -|[Index](#index)|`.index`|Indexes document(s) into Elasticsearch| -|[Webhook](#webhook)|`.webhook`|Send a payload to a web service using HTTP POST or PUT| -|[PagerDuty](#pagerduty)|`.pagerduty`|Trigger, resolve, or acknowlege an incident to a PagerDuty service| +| Type | Id | Description | +| ------------------------- | ------------- | ------------------------------------------------------------------ | +| [Server log](#server-log) | `.log` | Logs messages to the Kibana log using `server.log()` | +| [Email](#email) | `.email` | Sends an email using SMTP | +| [Slack](#slack) | `.slack` | Posts a message to a slack channel | +| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch | +| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT | +| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service | +| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance | + +--- ----- ## Server log ID: `.log` The params properties are modelled after the arguments to the [Hapi.server.log()](https://hapijs.com/api#-serverlogtags-data-timestamp) function. -### `config` +### `config` This action has no `config` properties. @@ -282,12 +286,13 @@ This action type has no `secrets` properties. ### `params` -|Property|Description|Type| -|---|---|---| -|message|The message to log.|string| -|tags|Tags associated with the message to log.|string[] _(optional)_| +| Property | Description | Type | +| -------- | ---------------------------------------- | --------------------- | +| message | The message to log. | string | +| tags | Tags associated with the message to log. | string[] _(optional)_ | + +--- ----- ## Email ID: `.email` @@ -296,50 +301,50 @@ This action type uses [nodemailer](https://nodemailer.com/about/) to send emails ### `config` -Either the property `service` must be provided, or the `host` and `port` properties must be provided. If `service` is provided, `host`, `port` and `secure` are ignored. For more information on the `gmail` service value specifically, see the [nodemailer gmail documentation](https://nodemailer.com/usage/using-gmail/). +Either the property `service` must be provided, or the `host` and `port` properties must be provided. If `service` is provided, `host`, `port` and `secure` are ignored. For more information on the `gmail` service value specifically, see the [nodemailer gmail documentation](https://nodemailer.com/usage/using-gmail/). -The `secure` property defaults to `false`. See the [nodemailer TLS documentation](https://nodemailer.com/smtp/#tls-options) for more information. +The `secure` property defaults to `false`. See the [nodemailer TLS documentation](https://nodemailer.com/smtp/#tls-options) for more information. -The `from` field can be specified as in typical `"user@host-name"` format, or as `"human name "` format. See the [nodemailer address documentation](https://nodemailer.com/message/addresses/) for more information. +The `from` field can be specified as in typical `"user@host-name"` format, or as `"human name "` format. See the [nodemailer address documentation](https://nodemailer.com/message/addresses/) for more information. -|Property|Description|Type| -|---|---|---| -|service|the name of a [well-known email service provider](https://nodemailer.com/smtp/well-known/)|string _(optional)_| -|host|host name of the service provider|string _(optional)_| -|port|port number of the service provider|number _(optional)_| -|secure|whether to use TLS with the service provider|boolean _(optional)_| -|from|the from address for all emails sent with this action type|string| +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------ | -------------------- | +| service | the name of a [well-known email service provider](https://nodemailer.com/smtp/well-known/) | string _(optional)_ | +| host | host name of the service provider | string _(optional)_ | +| port | port number of the service provider | number _(optional)_ | +| secure | whether to use TLS with the service provider | boolean _(optional)_ | +| from | the from address for all emails sent with this action type | string | ### `secrets` -|Property|Description|Type| -|---|---|---| -|user|userid to use with the service provider|string| -|password|password to use with the service provider|string| +| Property | Description | Type | +| -------- | ----------------------------------------- | ------ | +| user | userid to use with the service provider | string | +| password | password to use with the service provider | string | ### `params` There must be at least one entry in the `to`, `cc` and `bcc` arrays. -The message text will be sent as both plain text and html text. Additional function may be provided later. +The message text will be sent as both plain text and html text. Additional function may be provided later. The `to`, `cc`, and `bcc` array entries can be in the same format as the `from` property described in the config object above. -|Property|Description|Type| -|---|---|---| -|to|list of to addressees|string[] _(optional)_| -|cc|list of cc addressees|string[] _(optional)_| -|bcc|list of bcc addressees|string[] _(optional)_| -|subject|the subject line of the email|string| -|message|the message text|string| +| Property | Description | Type | +| -------- | ----------------------------- | --------------------- | +| to | list of to addressees | string[] _(optional)_ | +| cc | list of cc addressees | string[] _(optional)_ | +| bcc | list of bcc addressees | string[] _(optional)_ | +| subject | the subject line of the email | string | +| message | the message text | string | ----- +--- ## Slack ID: `.slack` -This action type interfaces with the [Slack Incoming Webhooks feature](https://api.slack.com/incoming-webhooks). Currently the params property `message` will be used as the `text` property of the Slack incoming message. Additional function may be provided later. +This action type interfaces with the [Slack Incoming Webhooks feature](https://api.slack.com/incoming-webhooks). Currently the params property `message` will be used as the `text` property of the Slack incoming message. Additional function may be provided later. ### `config` @@ -347,29 +352,29 @@ This action type has no `config` properties. ### `secrets` -|Property|Description|Type| -|---|---|---| -|webhookUrl|the url of the Slack incoming webhook|string| +| Property | Description | Type | +| ---------- | ------------------------------------- | ------ | +| webhookUrl | the url of the Slack incoming webhook | string | ### `params` -|Property|Description|Type| -|---|---|---| -|message|the message text|string| +| Property | Description | Type | +| -------- | ---------------- | ------ | +| message | the message text | string | ----- +--- ## Index ID: `.index` -The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elasticsearch/reference/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. +The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elasticsearch/reference/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. ### `config` -|Property|Description|Type| -|---|---|---| -|index|The Elasticsearch index to index into.|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------- | ------------------- | +| index | The Elasticsearch index to index into. | string _(optional)_ | ### `secrets` @@ -377,81 +382,114 @@ This action type has no `secrets` properties. ### `params` -|Property|Description|Type| -|---|---|---| -|index|The Elasticsearch index to index into.|string _(optional)_| -|doc_id|The optional _id of the document.|string _(optional)_| -|execution_time_field|The field that will store/index the action execution time.|string _(optional)_| -|refresh|Setting of the refresh policy for the write request|boolean _(optional)_| -|body|The documument body/bodies to index.|object or object[]| +| Property | Description | Type | +| -------------------- | ---------------------------------------------------------- | -------------------- | +| index | The Elasticsearch index to index into. | string _(optional)_ | +| doc_id | The optional \_id of the document. | string _(optional)_ | +| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ | +| refresh | Setting of the refresh policy for the write request | boolean _(optional)_ | +| body | The documument body/bodies to index. | object or object[] | + +--- ----- ## Webhook ID: `.webhook` The webhook action uses [axios](https://github.com/axios/axios) to send a POST or PUT request to a web service. -### `config` +### `config` -|Property|Description|Type| -|---|---|---| -|url|Request URL|string| -|method|HTTP request method, either `post`_(default)_ or `put`|string _(optional)_| -|headers|Key-value pairs of the headers to send with the request|object, keys and values are strings _(optional)_| +| Property | Description | Type | +| -------- | ------------------------------------------------------- | ------------------------------------------------ | +| url | Request URL | string | +| method | HTTP request method, either `post`_(default)_ or `put` | string _(optional)_ | +| headers | Key-value pairs of the headers to send with the request | object, keys and values are strings _(optional)_ | -### `secrets` +### `secrets` -|Property|Description|Type| -|---|---|---| -|user|Username for HTTP Basic authentication|string _(optional)_| -|password|Password for HTTP Basic authentication|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------- | ------------------- | +| user | Username for HTTP Basic authentication | string _(optional)_ | +| password | Password for HTTP Basic authentication | string _(optional)_ | -### `params` +### `params` -|Property|Description|Type| -|---|---|---| -|body|The HTTP request body|string _(optional)_| +| Property | Description | Type | +| -------- | --------------------- | ------------------- | +| body | The HTTP request body | string _(optional)_ | ----- +--- ## PagerDuty -ID: `.pagerduty` +ID: `.pagerduty` -The PagerDuty action uses the [V2 Events API](https://v2.developer.pagerduty.com/docs/events-api-v2) to trigger, acknowlege, and resolve PagerDuty alerts. +The PagerDuty action uses the [V2 Events API](https://v2.developer.pagerduty.com/docs/events-api-v2) to trigger, acknowlege, and resolve PagerDuty alerts. -### `config` +### `config` -|Property|Description|Type| -|---|---|---| -|apiUrl|PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------------------------------------------- | ------------------- | +| apiUrl | PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue` | string _(optional)_ | ### `secrets` -|Property|Description|Type| -|---|---|---| -|routingKey|This is the 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset.|string| +| Property | Description | Type | +| ---------- | ---------------------------------------------------------------------------------------------------------- | ------ | +| routingKey | This is the 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. | string | -### `params` +### `params` -|Property|Description|Type| -|---|---|---| -|eventAction|One of `trigger` _(default)_, `resolve`, or `acknowlege`. See [event action](https://v2.developer.pagerduty.com/docs/events-api-v2#event-action) for more details.| string _(optional)_| -|dedupKey|All actions sharing this key will be associated with the same PagerDuty alert. Used to correlate trigger and resolution. Defaults to `action:`. The maximum length is **255** characters. See [alert deduplication](https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication) for details. | string _(optional)_| -|summary|A text summary of the event, defaults to `No summary provided`. The maximum length is **1024** characters. | string _(optional)_| -|source|The affected system, preferably a hostname or fully qualified domain name. Defaults to `Kibana Action `.| string _(optional)_| -|severity|The perceived severity of on the affected system. This can be one of `critical`, `error`, `warning` or `info`_(default)_.| string _(optional)_| -|timestamp|An [ISO-8601 format date-time](https://v2.developer.pagerduty.com/v2/docs/types#datetime), indicating the time the event was detected or generated.| string _(optional)_| -|component|The component of the source machine that is responsible for the event, for example `mysql` or `eth0`.| string _(optional)_| -|group|Logical grouping of components of a service, for example `app-stack`.| string _(optional)_| -|class|The class/type of the event, for example `ping failure` or `cpu load`.| string _(optional)_| +| Property | Description | Type | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| eventAction | One of `trigger` _(default)_, `resolve`, or `acknowlege`. See [event action](https://v2.developer.pagerduty.com/docs/events-api-v2#event-action) for more details. | string _(optional)_ | +| dedupKey | All actions sharing this key will be associated with the same PagerDuty alert. Used to correlate trigger and resolution. Defaults to `action:`. The maximum length is **255** characters. See [alert deduplication](https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication) for details. | string _(optional)_ | +| summary | A text summary of the event, defaults to `No summary provided`. The maximum length is **1024** characters. | string _(optional)_ | +| source | The affected system, preferably a hostname or fully qualified domain name. Defaults to `Kibana Action `. | string _(optional)_ | +| severity | The perceived severity of on the affected system. This can be one of `critical`, `error`, `warning` or `info`_(default)_. | string _(optional)_ | +| timestamp | An [ISO-8601 format date-time](https://v2.developer.pagerduty.com/v2/docs/types#datetime), indicating the time the event was detected or generated. | string _(optional)_ | +| component | The component of the source machine that is responsible for the event, for example `mysql` or `eth0`. | string _(optional)_ | +| group | Logical grouping of components of a service, for example `app-stack`. | string _(optional)_ | +| class | The class/type of the event, for example `ping failure` or `cpu load`. | string _(optional)_ | For more details see [PagerDuty v2 event parameters](https://v2.developer.pagerduty.com/v2/docs/send-an-event-events-api-v2). +--- + +## ServiceNow + +ID: `.servicenow` + +The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents. + +### `config` + +| Property | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | ServiceNow instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| -------- | -------------------------------------- | ------ | +| username | Username for HTTP Basic authentication | string | +| password | Password for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| incidentID | The id of the incident in ServiceNow . If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + # Command Line Utility -The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: +The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: ```console $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack.com/services/T0000/B0000/XXXX"}' @@ -467,4 +505,4 @@ $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack. "updated_at": "2019-06-26T17:55:42.728Z", "version": "WzMsMV0=" } -``` \ No newline at end of file +``` diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts deleted file mode 100644 index cfd3a9d70dc932..00000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import axios, { AxiosResponse } from 'axios'; -import { Services } from '../../types'; -import { ParamsType, SecretsType } from '../servicenow'; - -interface PostServiceNowOptions { - apiUrl: string; - data: ParamsType; - headers: Record; - services?: Services; - secrets: SecretsType; -} - -// post an event to serviceNow -export async function postServiceNow(options: PostServiceNowOptions): Promise { - const { apiUrl, data, headers, secrets } = options; - const axiosOptions = { - headers, - validateStatus: () => true, - auth: secrets, - }; - return axios.post(`${apiUrl}/api/now/v1/table/incident`, data, axiosOptions); -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts deleted file mode 100644 index 9ae96cb23a5c39..00000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts +++ /dev/null @@ -1,279 +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. - */ - -jest.mock('./lib/post_servicenow', () => ({ - postServiceNow: jest.fn(), -})); - -import { getActionType } from './servicenow'; -import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; -import { validateConfig, validateSecrets, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { postServiceNow } from './lib/post_servicenow'; -import { createActionTypeRegistry } from './index.test'; -import { configUtilsMock } from '../actions_config.mock'; - -const postServiceNowMock = postServiceNow as jest.Mock; - -const ACTION_TYPE_ID = '.servicenow'; - -const services: Services = { - callCluster: async (path: string, opts: any) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; - -let actionType: ActionType; - -const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - }, - secrets: { - password: 'secret-password', - username: 'secret-username', - }, - params: { - comments: 'hello cool service now incident', - short_description: 'this is a cool service now incident', - }, -}; - -beforeAll(() => { - const { actionTypeRegistry } = createActionTypeRegistry(); - actionType = actionTypeRegistry.get(ACTION_TYPE_ID); -}); - -describe('get()', () => { - test('should return correct action type', () => { - expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('ServiceNow'); - }); -}); - -describe('validateConfig()', () => { - test('should validate and pass when config is valid', () => { - const { config } = mockServiceNow; - expect(validateConfig(actionType, config)).toEqual(config); - }); - - test('should validate and throw error when config is invalid', () => { - expect(() => { - validateConfig(actionType, { shouldNotBeHere: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` - ); - }); - - test('should validate and pass when the servicenow url is whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: url => { - expect(url).toEqual('https://events.servicenow.com/v2/enqueue'); - }, - }, - }); - - expect( - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }) - ).toEqual({ apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }); - - test('config validation returns an error if the specified URL isnt whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: _ => { - throw new Error(`target url is not whitelisted`); - }, - }, - }); - - expect(() => { - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` - ); - }); -}); - -describe('validateSecrets()', () => { - test('should validate and pass when secrets is valid', () => { - const { secrets } = mockServiceNow; - expect(validateSecrets(actionType, secrets)).toEqual(secrets); - }); - - test('should validate and throw error when secrets is invalid', () => { - expect(() => { - validateSecrets(actionType, { username: false }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` - ); - - expect(() => { - validateSecrets(actionType, { username: false, password: 'hello' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` - ); - }); -}); - -describe('validateParams()', () => { - test('should validate and pass when params is valid', () => { - const { params } = mockServiceNow; - expect(validateParams(actionType, params)).toEqual(params); - }); - - test('should validate and throw error when params is invalid', () => { - expect(() => { - validateParams(actionType, { eventAction: 'ackynollage' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action params: [short_description]: expected value of type [string] but got [undefined]"` - ); - }); -}); - -describe('execute()', () => { - beforeEach(() => { - postServiceNowMock.mockReset(); - }); - const { config, params, secrets } = mockServiceNow; - test('should succeed with valid params', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 201, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - const { apiUrl, data, headers } = postServiceNowMock.mock.calls[0][0]; - expect({ apiUrl, data, headers, secrets }).toMatchInlineSnapshot(` - Object { - "apiUrl": "www.servicenowisinkibanaactions.com", - "data": Object { - "comments": "hello cool service now incident", - "short_description": "this is a cool service now incident", - }, - "headers": Object { - "Accept": "application/json", - "Content-Type": "application/json", - }, - "secrets": Object { - "password": "secret-password", - "username": "secret-username", - }, - } - `); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "data": "data-here", - "status": "ok", - } - `); - }); - - test('should fail when postServiceNow throws', async () => { - postServiceNowMock.mockImplementation(() => { - throw new Error('doing some testing'); - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event", - "serviceMessage": "doing some testing", - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 429', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 429, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 429, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 501', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 501, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 501, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 418', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 418, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: unexpected status 418", - "status": "error", - } - `); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts deleted file mode 100644 index 0ad435281eba45..00000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.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 { curry } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { schema, TypeOf } from '@kbn/config-schema'; -import { - ActionType, - ActionTypeExecutorOptions, - ActionTypeExecutorResult, - ExecutorType, -} from '../types'; -import { ActionsConfigurationUtilities } from '../actions_config'; -import { postServiceNow } from './lib/post_servicenow'; - -// config definition -export type ConfigType = TypeOf; - -const ConfigSchemaProps = { - apiUrl: schema.string(), -}; - -const ConfigSchema = schema.object(ConfigSchemaProps); - -function validateConfig( - configurationUtilities: ActionsConfigurationUtilities, - configObject: ConfigType -) { - if (configObject.apiUrl == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiNullError', { - defaultMessage: 'ServiceNow [apiUrl] is required', - }); - } - try { - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { - defaultMessage: 'error configuring servicenow action: {message}', - values: { - message: whitelistError.message, - }, - }); - } -} -// secrets definition -export type SecretsType = TypeOf; -const SecretsSchemaProps = { - password: schema.string(), - username: schema.string(), -}; - -const SecretsSchema = schema.object(SecretsSchemaProps); - -function validateSecrets( - configurationUtilities: ActionsConfigurationUtilities, - secrets: SecretsType -) { - if (secrets.username == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiUserError', { - defaultMessage: 'error configuring servicenow action: no secrets [username] provided', - }); - } - if (secrets.password == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiPasswordError', { - defaultMessage: 'error configuring servicenow action: no secrets [password] provided', - }); - } -} - -// params definition - -export type ParamsType = TypeOf; - -const ParamsSchema = schema.object({ - comments: schema.maybe(schema.string()), - short_description: schema.string(), -}); - -// action type definition -export function getActionType({ - configurationUtilities, - executor = serviceNowExecutor, -}: { - configurationUtilities: ActionsConfigurationUtilities; - executor?: ExecutorType; -}): ActionType { - return { - id: '.servicenow', - name: i18n.translate('xpack.actions.builtin.servicenowTitle', { - defaultMessage: 'ServiceNow', - }), - validate: { - config: schema.object(ConfigSchemaProps, { - validate: curry(validateConfig)(configurationUtilities), - }), - secrets: schema.object(SecretsSchemaProps, { - validate: curry(validateSecrets)(configurationUtilities), - }), - params: ParamsSchema, - }, - executor, - }; -} - -// action executor - -async function serviceNowExecutor( - execOptions: ActionTypeExecutorOptions -): Promise { - const actionId = execOptions.actionId; - const config = execOptions.config as ConfigType; - const secrets = execOptions.secrets as SecretsType; - const params = execOptions.params as ParamsType; - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - let response; - try { - response = await postServiceNow({ apiUrl: config.apiUrl, data: params, headers, secrets }); - } catch (err) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingErrorMessage', { - defaultMessage: 'error posting servicenow event', - }); - return { - status: 'error', - actionId, - message, - serviceMessage: err.message, - }; - } - if (response.status === 200 || response.status === 201 || response.status === 204) { - return { - status: 'ok', - actionId, - data: response.data, - }; - } - - if (response.status === 429 || response.status >= 500) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { - defaultMessage: 'error posting servicenow event: http status {status}, retry later', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - retry: true, - }; - } - - const message = i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { - defaultMessage: 'error posting servicenow event: unexpected status {status}', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - }; -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts new file mode 100644 index 00000000000000..381b44439033ca --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { ServiceNow } from './lib'; +import { finalMapping } from './mock'; +import { Incident } from './lib/types'; + +jest.mock('./lib'); + +const ServiceNowMock = ServiceNow as jest.Mock; + +const incident: Incident = { + short_description: 'A title', + description: 'A description', +}; + +const comments = [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }, +]; + +describe('handleCreateIncident', () => { + beforeAll(() => { + ServiceNowMock.mockImplementation(() => { + return { + serviceNow: { + getUserID: jest.fn().mockResolvedValue('1234'), + createIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }), + updateIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }), + batchCreateComments: jest + .fn() + .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), + batchUpdateComments: jest + .fn() + .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), + }, + }; + }); + }); + + test('create an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create an incident with comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('update an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('update an incident and create new comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); + + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts new file mode 100644 index 00000000000000..47120c5da096dd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.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 { zipWith } from 'lodash'; +import { Incident, CommentResponse } from './lib/types'; +import { + ActionHandlerArguments, + UpdateParamsType, + UpdateActionHandlerArguments, + IncidentCreationResponse, + CommentType, + CommentsZipped, +} from './types'; +import { ServiceNow } from './lib'; + +const createComments = async ( + serviceNow: ServiceNow, + incidentId: string, + key: string, + comments: CommentType[] +): Promise => { + const createdComments = await serviceNow.batchCreateComments(incidentId, comments, key); + + return zipWith(comments, createdComments, (a: CommentType, b: CommentResponse) => ({ + commentId: a.commentId, + pushedDate: b.pushedDate, + })); +}; + +export const handleCreateIncident = async ({ + serviceNow, + params, + comments, + mapping, +}: ActionHandlerArguments): Promise => { + const paramsAsIncident = params as Incident; + + const { incidentId, number, pushedDate } = await serviceNow.createIncident({ + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = [ + ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ]; + } + + return { ...res }; +}; + +export const handleUpdateIncident = async ({ + incidentId, + serviceNow, + params, + comments, + mapping, +}: UpdateActionHandlerArguments): Promise => { + const paramsAsIncident = params as UpdateParamsType; + + const { number, pushedDate } = await serviceNow.updateIncident(incidentId, { + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = [ + ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ]; + } + + return { ...res }; +}; diff --git a/x-pack/legacy/plugins/transform/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts similarity index 64% rename from x-pack/legacy/plugins/transform/index.ts rename to x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts index a4b980c0bf8f3a..a0ffd859e14caa 100644 --- a/x-pack/legacy/plugins/transform/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts @@ -4,9 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export function transform(kibana: any) { - return new kibana.Plugin({ - id: 'transform', - configPrefix: 'xpack.transform', - }); -} +export const ACTION_TYPE_ID = '.servicenow'; +export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 00000000000000..96962b41b3c680 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { normalizeMapping, buildMap, mapParams } from './helpers'; +import { mapping, finalMapping } from './mock'; +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType } from './types'; + +const maliciousMapping: MapsType[] = [ + { source: '__proto__', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: '__proto__', actionType: 'nothing' }, + { source: 'comments', target: 'comments', actionType: 'nothing' }, + { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, +]; + +describe('sanitizeMapping', () => { + test('remove malicious fields', () => { + const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(sanitizedMapping.every(m => m.source !== '__proto__' && m.target !== '__proto__')).toBe( + true + ); + }); + + test('remove unsuppported source fields', () => { + const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(normalizedMapping).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'unsupportedSource', + target: 'comments', + actionType: 'nothing', + }), + ]) + ); + }); +}); + +describe('buildMap', () => { + test('builds sanitized Map', () => { + const finalMap = buildMap(maliciousMapping); + expect(finalMap.get('__proto__')).not.toBeDefined(); + }); + + test('builds Map correct', () => { + const final = buildMap(mapping); + expect(final).toEqual(finalMapping); + }); +}); + +describe('mapParams', () => { + test('maps params correctly', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + + const fields = mapParams(params, finalMapping); + + expect(fields).toEqual({ + short_description: 'Incident title', + description: 'Incident description', + }); + }); + + test('do not add fields not in mapping', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + const fields = mapParams(params, finalMapping); + + const { title, description, ...unexpectedFields } = params; + + expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 00000000000000..99e67c1c43f35e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts @@ -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 { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType, FinalMapping } from './types'; + +export const normalizeMapping = (fields: string[], mapping: MapsType[]): MapsType[] => { + // Prevent prototype pollution and remove unsupported fields + return mapping.filter( + m => m.source !== '__proto__' && m.target !== '__proto__' && fields.includes(m.source) + ); +}; + +export const buildMap = (mapping: MapsType[]): FinalMapping => { + return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { + const { source, target, actionType } = field; + fieldsMap.set(source, { target, actionType }); + fieldsMap.set(target, { target: source, actionType }); + return fieldsMap; + }, new Map()); +}; + +interface KeyAny { + [key: string]: unknown; +} + +export const mapParams = (params: any, mapping: FinalMapping) => { + return Object.keys(params).reduce((prev: KeyAny, curr: string): KeyAny => { + const field = mapping.get(curr); + if (field) { + prev[field.target] = params[curr]; + } + return prev; + }, {}); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts new file mode 100644 index 00000000000000..a1df243b0ee7c6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -0,0 +1,256 @@ +/* + * 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 { getActionType } from '.'; +import { ActionType, Services, ActionTypeExecutorOptions } from '../../types'; +import { validateConfig, validateSecrets, validateParams } from '../../lib'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { createActionTypeRegistry } from '../index.test'; +import { configUtilsMock } from '../../actions_config.mock'; + +import { ACTION_TYPE_ID } from './constants'; +import * as i18n from './translations'; + +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { incidentResponse } from './mock'; + +jest.mock('./action_handlers'); + +const handleCreateIncidentMock = handleCreateIncident as jest.Mock; +const handleUpdateIncidentMock = handleUpdateIncident as jest.Mock; + +const services: Services = { + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: savedObjectsClientMock.create(), +}; + +let actionType: ActionType; + +const mockOptions = { + name: 'servicenow-connector', + actionTypeId: '.servicenow', + secrets: { + username: 'secret-username', + password: 'secret-password', + }, + config: { + apiUrl: 'https://service-now.com', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'work_notes', + actionType: 'append', + }, + ], + }, + }, + params: { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], + }, +}; + +beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +describe('get()', () => { + test('should return correct action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual(i18n.NAME); + }); +}); + +describe('validateConfig()', () => { + test('should validate and pass when config is valid', () => { + const { config } = mockOptions; + expect(validateConfig(actionType, config)).toEqual(config); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when the servicenow url is whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: url => { + expect(url).toEqual(mockOptions.config.apiUrl); + }, + }, + }); + + expect(validateConfig(actionType, mockOptions.config)).toEqual(mockOptions.config); + }); + + test('config validation returns an error if the specified URL isnt whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: _ => { + throw new Error(`target url is not whitelisted`); + }, + }, + }); + + expect(() => { + validateConfig(actionType, mockOptions.config); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` + ); + }); +}); + +describe('validateSecrets()', () => { + test('should validate and pass when secrets is valid', () => { + const { secrets } = mockOptions; + expect(validateSecrets(actionType, secrets)).toEqual(secrets); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(actionType, { username: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { username: false, password: 'hello' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` + ); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + const { params } = mockOptions; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [caseId]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + handleCreateIncidentMock.mockReset(); + handleUpdateIncidentMock.mockReset(); + }); + + test('should create an incident', async () => { + const actionId = 'some-id'; + const { incidentId, ...rest } = mockOptions.params; + + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + + handleCreateIncidentMock.mockImplementation(() => incidentResponse); + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); + }); + + test('should throw an error when failed to create incident', async () => { + expect.assertions(1); + const { incidentId, ...rest } = mockOptions.params; + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to create incident'; + + handleCreateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); + + test('should update an incident', async () => { + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok' }); + }); + + test('should throw an error when failed to update an incident', async () => { + expect.assertions(1); + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to update incident'; + + handleUpdateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts new file mode 100644 index 00000000000000..01e566af17d08b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -0,0 +1,115 @@ +/* + * 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 { curry, isEmpty } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { + ActionType, + ActionTypeExecutorOptions, + ActionTypeExecutorResult, + ExecutorType, +} from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ServiceNow } from './lib'; + +import * as i18n from './translations'; + +import { ACTION_TYPE_ID } from './constants'; +import { ConfigType, SecretsType, ParamsType, CommentType } from './types'; + +import { ConfigSchemaProps, SecretsSchemaProps, ParamsSchema } from './schema'; + +import { buildMap, mapParams } from './helpers'; +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; + +function validateConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConfigType +) { + try { + if (isEmpty(configObject.casesConfiguration.mapping)) { + return i18n.MAPPING_EMPTY; + } + + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +} + +function validateSecrets( + configurationUtilities: ActionsConfigurationUtilities, + secrets: SecretsType +) {} + +// action type definition +export function getActionType({ + configurationUtilities, + executor = serviceNowExecutor, +}: { + configurationUtilities: ActionsConfigurationUtilities; + executor?: ExecutorType; +}): ActionType { + return { + id: ACTION_TYPE_ID, + name: i18n.NAME, + validate: { + config: schema.object(ConfigSchemaProps, { + validate: curry(validateConfig)(configurationUtilities), + }), + secrets: schema.object(SecretsSchemaProps, { + validate: curry(validateSecrets)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor, + }; +} + +// action executor + +async function serviceNowExecutor( + execOptions: ActionTypeExecutorOptions +): Promise { + const actionId = execOptions.actionId; + const { + apiUrl, + casesConfiguration: { mapping }, + } = execOptions.config as ConfigType; + const { username, password } = execOptions.secrets as SecretsType; + const params = execOptions.params as ParamsType; + const { comments, incidentId, ...restParams } = params; + + const finalMap = buildMap(mapping); + const restParamsMapped = mapParams(restParams, finalMap); + const serviceNow = new ServiceNow({ url: apiUrl, username, password }); + + const handlerInput = { + serviceNow, + params: restParamsMapped, + comments: comments as CommentType[], + mapping: finalMap, + }; + + const res: Pick & + Pick = { + status: 'ok', + actionId, + }; + + let data = {}; + + if (!incidentId) { + data = await handleCreateIncident(handlerInput); + } else { + data = await handleUpdateIncident({ incidentId, ...handlerInput }); + } + + return { + ...res, + data, + }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts new file mode 100644 index 00000000000000..c84e1928e2e5a7 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const API_VERSION = 'v2'; +export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; +export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; +export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts new file mode 100644 index 00000000000000..22be625611e853 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts @@ -0,0 +1,232 @@ +/* + * 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 axios from 'axios'; +import { ServiceNow } from '.'; +import { instance, params } from '../mock'; + +jest.mock('axios'); + +axios.create = jest.fn(() => axios); +const axiosMock = (axios as unknown) as jest.Mock; + +let serviceNow: ServiceNow; + +const testMissingConfiguration = (field: string) => { + expect.assertions(1); + try { + new ServiceNow({ ...instance, [field]: '' }); + } catch (error) { + expect(error.message).toEqual('[Action][ServiceNow]: Wrong configuration.'); + } +}; + +const prependInstanceUrl = (url: string): string => `${instance.url}/${url}`; + +describe('ServiceNow lib', () => { + beforeEach(() => { + serviceNow = new ServiceNow(instance); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should thrown an error if url is missing', () => { + testMissingConfiguration('url'); + }); + + test('should thrown an error if username is missing', () => { + testMissingConfiguration('username'); + }); + + test('should thrown an error if password is missing', () => { + testMissingConfiguration('password'); + }); + + test('get user id', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: [{ sys_id: '123' }] }, + }); + + const res = await serviceNow.getUserID(); + const [url, { method }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/sys_user?user_name=username')); + expect(method).toEqual('get'); + expect(res).toEqual('123'); + }); + + test('create incident', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + }); + + const res = await serviceNow.createIncident({ + short_description: 'A title', + description: 'A description', + caller_id: '123', + }); + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident')); + expect(method).toEqual('post'); + expect(data).toEqual({ + short_description: 'A title', + description: 'A description', + caller_id: '123', + }); + + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('update incident', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + const res = await serviceNow.updateIncident('123', { + short_description: params.title, + }); + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ short_description: params.title }); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create comment', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + const comment = { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }; + + const res = await serviceNow.createComment('123', comment, 'comments'); + + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ + comments: 'A comment', + }); + + expect(res).toEqual({ + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create batch comment', async () => { + axiosMock.mockReturnValueOnce({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + axiosMock.mockReturnValueOnce({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:25:20' } }, + }); + + const comments = [ + { + commentId: '123', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }, + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A second comment', + incidentCommentId: undefined, + }, + ]; + const res = await serviceNow.batchCreateComments('000', comments, 'comments'); + + comments.forEach((comment, index) => { + const [url, { method, data }] = axiosMock.mock.calls[index]; + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident/000')); + expect(method).toEqual('patch'); + expect(data).toEqual({ + comments: comment.comment, + }); + expect(res).toEqual([ + { commentId: '123', pushedDate: '2020-03-10T12:24:20.000Z' }, + { commentId: '456', pushedDate: '2020-03-10T12:25:20.000Z' }, + ]); + }); + }); + + test('throw if not status is not ok', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ + status: 401, + headers: { + 'content-type': 'application/json', + }, + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); + + test('throw if not content-type is not application/json', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/html', + }, + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts new file mode 100644 index 00000000000000..b3d17affb14c2c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -0,0 +1,129 @@ +/* + * 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 axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; + +import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; +import { CommentType } from '../types'; + +const validStatusCodes = [200, 201]; + +class ServiceNow { + private readonly incidentUrl: string; + private readonly commentUrl: string; + private readonly userUrl: string; + private readonly axios: AxiosInstance; + + constructor(private readonly instance: Instance) { + if ( + !this.instance || + !this.instance.url || + !this.instance.username || + !this.instance.password + ) { + throw Error('[Action][ServiceNow]: Wrong configuration.'); + } + + this.incidentUrl = `${this.instance.url}/${INCIDENT_URL}`; + this.commentUrl = `${this.instance.url}/${COMMENT_URL}`; + this.userUrl = `${this.instance.url}/${USER_URL}`; + this.axios = axios.create({ + auth: { username: this.instance.username, password: this.instance.password }, + }); + } + + private _throwIfNotAlive(status: number, contentType: string) { + if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { + throw new Error('[ServiceNow]: Instance is not alive.'); + } + } + + private async _request({ + url, + method = 'get', + data = {}, + }: { + url: string; + method?: Method; + data?: any; + }): Promise { + const res = await this.axios(url, { method, data }); + this._throwIfNotAlive(res.status, res.headers['content-type']); + return res; + } + + private _patch({ url, data }: { url: string; data: any }): Promise { + return this._request({ + url, + method: 'patch', + data, + }); + } + + private _addTimeZoneToDate(date: string, timezone = 'GMT'): string { + return `${date} GMT`; + } + + async getUserID(): Promise { + const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); + return res.data.result[0].sys_id; + } + + async createIncident(incident: Incident): Promise { + const res = await this._request({ + url: `${this.incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + }; + } + + async updateIncident(incidentId: string, incident: UpdateIncident): Promise { + const res = await this._patch({ + url: `${this.incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } + + async batchCreateComments( + incidentId: string, + comments: CommentType[], + field: string + ): Promise { + const res = await Promise.all(comments.map(c => this.createComment(incidentId, c, field))); + return res; + } + + async createComment( + incidentId: string, + comment: CommentType, + field: string + ): Promise { + const res = await this._patch({ + url: `${this.commentUrl}/${incidentId}`, + data: { [field]: comment.comment }, + }); + + return { + commentId: comment.commentId, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } +} + +export { ServiceNow }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts new file mode 100644 index 00000000000000..4a3c5c42fcb440 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts @@ -0,0 +1,30 @@ +/* + * 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 interface Instance { + url: string; + username: string; + password: string; +} + +export interface Incident { + short_description?: string; + description?: string; + caller_id?: string; +} + +export interface IncidentResponse { + number: string; + incidentId: string; + pushedDate: string; +} + +export interface CommentResponse { + commentId: string; + pushedDate: string; +} + +export type UpdateIncident = Partial; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts new file mode 100644 index 00000000000000..9a150bbede5f88 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.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 { MapsType, FinalMapping, ParamsType } from './types'; +import { Incident } from './lib/types'; + +const mapping: MapsType[] = [ + { source: 'title', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: 'description', actionType: 'nothing' }, + { source: 'comments', target: 'comments', actionType: 'nothing' }, +]; + +const finalMapping: FinalMapping = new Map(); + +finalMapping.set('title', { + target: 'short_description', + actionType: 'nothing', +}); + +finalMapping.set('description', { + target: 'description', + actionType: 'nothing', +}); + +finalMapping.set('comments', { + target: 'comments', + actionType: 'nothing', +}); + +finalMapping.set('short_description', { + target: 'title', + actionType: 'nothing', +}); + +const params: ParamsType = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '263ede42075300100e48fbbf7c1ed047', + }, + { + commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', + version: 'WlK3LDFd', + comment: 'Another comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], +}; + +const incidentResponse = { + incidentId: 'c816f79cc0a8016401c5a33be04be441', + number: 'INC0010001', +}; + +const userId = '2e9a0a5e2f79001016ab51172799b670'; + +const axiosResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + }, +}; +const userIdResponse = { + result: [{ sys_id: userId }], +}; + +const incidentAxiosResponse = { + result: { sys_id: incidentResponse.incidentId, number: incidentResponse.number }, +}; + +const instance = { + url: 'https://instance.service-now.com', + username: 'username', + password: 'password', +}; + +const incident: Incident = { + short_description: params.title, + description: params.description, + caller_id: userId, +}; + +export { + mapping, + finalMapping, + params, + incidentResponse, + incidentAxiosResponse, + userId, + userIdResponse, + axiosResponse, + instance, + incident, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts new file mode 100644 index 00000000000000..0bb4f50819665e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -0,0 +1,55 @@ +/* + * 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'; + +export const MapsSchema = schema.object({ + source: schema.string(), + target: schema.string(), + actionType: schema.oneOf([ + schema.literal('nothing'), + schema.literal('overwrite'), + schema.literal('append'), + ]), +}); + +export const CasesConfigurationSchema = schema.object({ + mapping: schema.arrayOf(MapsSchema), +}); + +export const ConfigSchemaProps = { + apiUrl: schema.string(), + casesConfiguration: CasesConfigurationSchema, +}; + +export const ConfigSchema = schema.object(ConfigSchemaProps); + +export const SecretsSchemaProps = { + password: schema.string(), + username: schema.string(), +}; + +export const SecretsSchema = schema.object(SecretsSchemaProps); + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + version: schema.maybe(schema.string()), + incidentCommentId: schema.maybe(schema.string()), +}); + +export const ExecutorAction = schema.oneOf([ + schema.literal('newIncident'), + schema.literal('updateIncident'), +]); + +export const ParamsSchema = schema.object({ + caseId: schema.string(), + comments: schema.maybe(schema.arrayOf(CommentSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + incidentId: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts new file mode 100644 index 00000000000000..8601c5ce772db3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const API_URL_REQUIRED = i18n.translate( + 'xpack.actions.builtin.servicenow.servicenowApiNullError', + { + defaultMessage: 'ServiceNow [apiUrl] is required', + } +); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { + defaultMessage: 'error configuring servicenow action: {message}', + values: { + message, + }, + }); + +export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { + defaultMessage: 'ServiceNow', +}); + +export const MAPPING_EMPTY = i18n.translate('xpack.actions.builtin.servicenow.emptyMapping', { + defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', +}); + +export const ERROR_POSTING = i18n.translate( + 'xpack.actions.builtin.servicenow.postingErrorMessage', + { + defaultMessage: 'error posting servicenow event', + } +); + +export const RETRY_POSTING = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { + defaultMessage: 'error posting servicenow event: http status {status}, retry later', + values: { + status, + }, + }); + +export const UNEXPECTED_STATUS = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { + defaultMessage: 'error posting servicenow event: unexpected status {status}', + values: { + status, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts new file mode 100644 index 00000000000000..7442f14fed0648 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { + ConfigSchema, + SecretsSchema, + ParamsSchema, + CasesConfigurationSchema, + MapsSchema, + CommentSchema, +} from './schema'; + +import { ServiceNow } from './lib'; + +// config definition +export type ConfigType = TypeOf; + +// secrets definition +export type SecretsType = TypeOf; + +export type ParamsType = TypeOf; + +export type CasesConfigurationType = TypeOf; +export type MapsType = TypeOf; +export type CommentType = TypeOf; + +export type FinalMapping = Map; + +export interface ActionHandlerArguments { + serviceNow: ServiceNow; + params: any; + comments: CommentType[]; + mapping: FinalMapping; +} + +export type UpdateParamsType = Partial; +export type UpdateActionHandlerArguments = ActionHandlerArguments & { + incidentId: string; +}; + +export interface IncidentCreationResponse { + incidentId: string; + number: string; + comments?: CommentsZipped[]; + pushedDate: string; +} + +export interface CommentsZipped { + commentId: string; + pushedDate: string; +} 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 6e12ffb6404c62..69b357196dc32f 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 @@ -30,7 +30,7 @@ export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 399f09bd3e776c..d318171f3bb48b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -37,9 +37,14 @@ const FIRED_ACTIONS = { }; const getCurrentValueFromAggregations = (aggregations: Aggregation) => { - const { buckets } = aggregations.aggregatedIntervals; - const { value } = buckets[buckets.length - 1].aggregatedValue; - return value; + try { + const { buckets } = aggregations.aggregatedIntervals; + if (!buckets.length) return null; // No Data state + const { value } = buckets[buckets.length - 1].aggregatedValue; + return value; + } catch (e) { + return undefined; // Error state + } }; const getParsedFilterQuery: ( @@ -138,34 +143,37 @@ const getMetric: ( aggs, }; - if (groupBy) { - const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> - ) => response.aggregations?.groupings?.buckets || []; - const afterKeyHandler = createAfterKeyHandler( - 'aggs.groupings.composite.after', - response => response.aggregations?.groupings?.after_key - ); - const compositeBuckets = (await getAllCompositeData( - body => callCluster('search', { body, index: indexPattern }), - searchBody, - bucketSelector, - afterKeyHandler - )) as Array; - return compositeBuckets.reduce( - (result, bucket) => ({ - ...result, - [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket), - }), - {} - ); + try { + if (groupBy) { + const bucketSelector = ( + response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> + ) => response.aggregations?.groupings?.buckets || []; + const afterKeyHandler = createAfterKeyHandler( + 'aggs.groupings.composite.after', + response => response.aggregations?.groupings?.after_key + ); + const compositeBuckets = (await getAllCompositeData( + body => callCluster('search', { body, index: indexPattern }), + searchBody, + bucketSelector, + afterKeyHandler + )) as Array; + return compositeBuckets.reduce( + (result, bucket) => ({ + ...result, + [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket), + }), + {} + ); + } + const result = await callCluster('search', { + body: searchBody, + index: indexPattern, + }); + return { '*': getCurrentValueFromAggregations(result.aggregations) }; + } catch (e) { + return { '*': undefined }; // Trigger an Error state } - - const result = await callCluster('search', { - body: searchBody, - index: indexPattern, - }); - return { '*': getCurrentValueFromAggregations(result.aggregations) }; }; const comparatorMap = { @@ -220,14 +228,15 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet criteria.map(criterion => (async () => { const currentValues = await getMetric(services, criterion, groupBy, filterQuery); - if (typeof currentValues === 'undefined') - throw new Error('Could not get current value of metric'); const { threshold, comparator } = criterion; const comparisonFunction = comparatorMap[comparator]; return mapValues(currentValues, value => ({ - shouldFire: comparisonFunction(value, threshold), + shouldFire: + value !== undefined && value !== null && comparisonFunction(value, threshold), currentValue: value, + isNoData: value === null, + isError: value === undefined, })); })() ) @@ -237,8 +246,12 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet for (const group of groups) { const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); + // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every(result => result[group].shouldFire); - + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = alertResults.some(result => result[group].isNoData); + const isError = alertResults.some(result => result[group].isError); if (shouldAlertFire) { alertInstance.scheduleActions(FIRED_ACTIONS.id, { group, @@ -248,7 +261,13 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet // Future use: ability to fetch display current alert state alertInstance.replaceState({ - alertState: shouldAlertFire ? AlertStates.ALERT : AlertStates.OK, + alertState: isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK, }); } }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 1c3d0cea3dc841..e247eb8a3f8891 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -19,6 +19,8 @@ export enum Comparator { export enum AlertStates { OK, ALERT, + NO_DATA, + ERROR, } export type TimeUnit = 's' | 'm' | 'h' | 'd'; diff --git a/x-pack/plugins/transform/public/app/index.scss b/x-pack/plugins/transform/public/app/index.scss index 836929174875ec..beb5ee6be67e66 100644 --- a/x-pack/plugins/transform/public/app/index.scss +++ b/x-pack/plugins/transform/public/app/index.scss @@ -15,4 +15,3 @@ @import 'sections/create_transform/components/wizard/index'; @import 'sections/transform_management/components/create_transform_button/index'; @import 'sections/transform_management/components/stats_bar/index'; -@import 'sections/transform_management/components/transform_list/index'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap index e7a5e027e6f8d6..e2de4c0ea1f6c0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap @@ -1,28 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Transform List Minimal initialization 1`] = ` - - - - Create your first transform - , - ] - } - data-test-subj="transformNoTransformsFound" - title={ -

- No transforms found -

- } - /> - + + Create your first transform + , + ] + } + data-test-subj="transformNoTransformsFound" + title={ +

+ No transforms found +

+ } +/> `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss deleted file mode 100644 index acb4bd0cf43264..00000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'transform_table'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss deleted file mode 100644 index a9e2e4d790436d..00000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss +++ /dev/null @@ -1,48 +0,0 @@ -.transform__TransformTable { - // Using an override as a last resort because we cannot set custom classes on - // nested upstream components. The opening animation limits the height - // of the expanded row to 1000px which turned out to be not predictable. - // The animation could also result in flickering with expanded rows - // where the inner content would result in the DOM changing the height. - .euiTableRow-isExpandedRow .euiTableCellContent { - animation: none !important; - .euiTableCellContent__text { - width: 100%; - } - } - // Another override: Because an update to the table replaces the DOM, the same - // icon would still again fade in with an animation. If the table refreshes with - // e.g. 1s this would result in a blinking icon effect. - .euiIcon-isLoaded { - animation: none !important; - } -} -.transform__BulkActionItem { - display: block; - padding: $euiSizeS; - width: 100%; - text-align: left; -} - -.transform__BulkActionsBorder { - height: 20px; - border-right: $euiBorderThin; - width: 1px; - display: inline-block; - vertical-align: middle; - height: 35px; - margin: 0px 5px; - margin-top: -5px; -} - -.transform__ProgressBar { - margin-bottom: $euiSizeM; -} - -.transform__TaskStateBadge, .transform__TaskModeBadge { - max-width: 100px; -} - -.transform__TransformTable__messagesPaneTable .euiTableCellContent__text { - text-align: left; -} diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx index fb24ff2a12e022..159833354b5efb 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx @@ -9,6 +9,9 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiBadge, + EuiTableActionsColumnType, + EuiTableComputedColumnType, + EuiTableFieldDataColumnType, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, @@ -21,13 +24,6 @@ import { import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; -import { - ActionsColumnType, - ComputedColumnType, - ExpanderColumnType, - FieldDataColumnType, -} from '../../../../../shared_imports'; - import { getTransformProgress, TransformListRow, @@ -89,15 +85,15 @@ export const getColumns = ( } const columns: [ - ExpanderColumnType, - FieldDataColumnType, - FieldDataColumnType, - FieldDataColumnType, - FieldDataColumnType, - ComputedColumnType, - ComputedColumnType, - ComputedColumnType, - ActionsColumnType + EuiTableComputedColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableComputedColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType ] = [ { name: ( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index 9c2da53c36d6bf..3393aada8b69d8 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, MouseEventHandler, FC, useContext, useState } from 'react'; +import React, { MouseEventHandler, FC, useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { + Direction, EuiBadge, EuiButtonEmpty, EuiButtonIcon, @@ -16,15 +17,13 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, + EuiInMemoryTable, EuiPopover, EuiTitle, - Direction, } from '@elastic/eui'; import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; -import { OnTableChangeArg, SortDirection, SORT_DIRECTION } from '../../../../../shared_imports'; - import { useRefreshTransformList, TransformListRow, @@ -43,7 +42,6 @@ import { StopAction } from './action_stop'; import { ItemIdToExpandedRowMap, Query, Clause } from './common'; import { getColumns } from './columns'; import { ExpandedRow } from './expanded_row'; -import { ProgressBar, transformTableFactory } from './transform_table'; function getItemIdToExpandedRowMap( itemIds: TransformId[], @@ -74,8 +72,6 @@ interface Props { transformsLoading: boolean; } -const TransformTable = transformTableFactory(); - export const TransformList: FC = ({ errorMessage, isInitialized, @@ -100,7 +96,7 @@ export const TransformList: FC = ({ const [pageSize, setPageSize] = useState(10); const [sortField, setSortField] = useState(TRANSFORM_LIST_COLUMN.ID); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + const [sortDirection, setSortDirection] = useState('asc'); const { capabilities } = useContext(AuthorizationContext); const disabled = @@ -186,52 +182,46 @@ export const TransformList: FC = ({ // Before the transforms have been loaded for the first time, display the loading indicator only. // Otherwise a user would see 'No transforms found' during the initial loading. if (!isInitialized) { - return ; + return null; } if (typeof errorMessage !== 'undefined') { return ( - - - -
{JSON.stringify(errorMessage)}
-
-
+ +
{JSON.stringify(errorMessage)}
+
); } if (transforms.length === 0) { return ( - - - - {i18n.translate('xpack.transform.list.emptyPromptTitle', { - defaultMessage: 'No transforms found', - })} - - } - actions={[ - - {i18n.translate('xpack.transform.list.emptyPromptButtonText', { - defaultMessage: 'Create your first transform', - })} - , - ]} - data-test-subj="transformNoTransformsFound" - /> - + + {i18n.translate('xpack.transform.list.emptyPromptTitle', { + defaultMessage: 'No transforms found', + })} + + } + actions={[ + + {i18n.translate('xpack.transform.list.emptyPromptButtonText', { + defaultMessage: 'Create your first transform', + })} + , + ]} + data-test-subj="transformNoTransformsFound" + /> ); } @@ -362,15 +352,15 @@ export const TransformList: FC = ({ const onTableChange = ({ page = { index: 0, size: 10 }, - sort = { field: TRANSFORM_LIST_COLUMN.ID, direction: SORT_DIRECTION.ASC }, - }: OnTableChangeArg) => { + sort = { field: TRANSFORM_LIST_COLUMN.ID as string, direction: 'asc' }, + }) => { const { index, size } = page; setPageIndex(index); setPageSize(size); const { field, direction } = sort; - setSortField(field); - setSortDirection(direction); + setSortField(field as string); + setSortDirection(direction as Direction); }; const selection = { @@ -379,8 +369,7 @@ export const TransformList: FC = ({ return (
- - = ({ items={filterActive ? filteredTransforms : transforms} itemId={TRANSFORM_LIST_COLUMN.ID} itemIdToExpandedRowMap={itemIdToExpandedRowMap} + loading={isLoading || transformsLoading} onTableChange={onTableChange} pagination={pagination} rowProps={item => ({ @@ -399,11 +389,9 @@ export const TransformList: FC = ({ selection={selection} sorting={sorting} search={search} - data-test-subj={ - isLoading || transformsLoading - ? 'transformListTable loading' - : 'transformListTable loaded' - } + data-test-subj={`transformListTable ${ + isLoading || transformsLoading ? 'loading' : 'loaded' + }`} />
); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx deleted file mode 100644 index 8c7920c124beff..00000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx +++ /dev/null @@ -1,107 +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. - */ - -// This component extends EuiInMemoryTable with some -// fixes and TS specs until the changes become available upstream. - -import React, { Fragment } from 'react'; - -import { EuiProgress } from '@elastic/eui'; - -import { mlInMemoryTableBasicFactory } from '../../../../../shared_imports'; - -// The built in loading progress bar of EuiInMemoryTable causes a full DOM replacement -// of the table and doesn't play well with auto-refreshing. That's why we're displaying -// our own progress bar on top of the table. `EuiProgress` after `isLoading` displays -// the loading indicator. The variation after `!isLoading` displays an empty progress -// bar fixed to 0%. Without it, the display would vertically jump when showing/hiding -// the progress bar. -export const ProgressBar = ({ isLoading = false }) => { - return ( - - {isLoading && } - {!isLoading && ( - - )} - - ); -}; - -// copied from EUI to be available to the extended getDerivedStateFromProps() -function findColumnByProp(columns: any, prop: any, value: any) { - for (let i = 0; i < columns.length; i++) { - const column = columns[i]; - if (column[prop] === value) { - return column; - } - } -} - -// copied from EUI to be available to the extended getDerivedStateFromProps() -const getInitialSorting = (columns: any, sorting: any) => { - if (!sorting || !sorting.sort) { - return { - sortName: undefined, - sortDirection: undefined, - }; - } - - const { field: sortable, direction: sortDirection } = sorting.sort; - - // sortable could be a column's `field` or its `name` - // for backwards compatibility `field` must be checked first - let sortColumn = findColumnByProp(columns, 'field', sortable); - if (sortColumn == null) { - sortColumn = findColumnByProp(columns, 'name', sortable); - } - - if (sortColumn == null) { - return { - sortName: undefined, - sortDirection: undefined, - }; - } - - const sortName = sortColumn.name; - - return { - sortName, - sortDirection, - }; -}; - -export function transformTableFactory() { - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - return class TransformTable extends MlInMemoryTableBasic { - static getDerivedStateFromProps(nextProps: any, prevState: any) { - const derivedState = { - ...prevState.prevProps, - pageIndex: nextProps.pagination.initialPageIndex, - pageSize: nextProps.pagination.initialPageSize, - }; - - if (nextProps.items !== prevState.prevProps.items) { - Object.assign(derivedState, { - prevProps: { - items: nextProps.items, - }, - }); - } - - const { sortName, sortDirection } = getInitialSorting(nextProps.columns, nextProps.sorting); - if ( - sortName !== prevState.prevProps.sortName || - sortDirection !== prevState.prevProps.sortDirection - ) { - Object.assign(derivedState, { - sortName, - sortDirection, - }); - } - return derivedState; - } - }; -} diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index 3582dd5d266e2f..4def1bc98ef8cf 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -24,21 +24,6 @@ export { DAY, } from '../../../../src/plugins/es_ui_shared/public/components/cron_editor'; -// Custom version of EuiInMemoryTable with TypeScript -// support and a fix for updating sorting props. -export { - ActionsColumnType, - ComputedColumnType, - ExpanderColumnType, - FieldDataColumnType, - ColumnType, - mlInMemoryTableBasicFactory, - OnTableChangeArg, - SortingPropType, - SortDirection, - SORT_DIRECTION, -} from '../../../legacy/plugins/ml/public/application/components/ml_in_memory_table'; - // Needs to be imported because we're reusing KqlFilterBar which depends on it. export { setDependencyCache } from '../../../legacy/plugins/ml/public/application/util/dependency_cache'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b64caea05dbe4b..5b55ad3433e225 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1116,7 +1116,6 @@ "kbn.discover.noResults.indexFailureIndexText": "インデックス {failureIndex}", "kbn.discover.noResults.indexFailureShardText": "{index}、シャード {failureShard}", "kbn.discover.noResults.queryMayNotMatchTitle": "表示されているインデックスの 1 つまたは複数にデータフィールドが含まれています。クエリが現在の時間範囲のデータと一致しないか、現在選択された時間範囲にデータが全く存在しない可能性があります。データが存在する時間範囲に変えることができます。", - "kbn.discover.noResults.requiredPluginIsNotInstalledOrDisabledTitle": "{unsupportedType} インデックスに基づくインデックスパターンには X-Pack の {unsupportedType} プラグインが必要で、このプラグインは現在インストールされていないか無効になっています。", "kbn.discover.noResults.searchExamples.400to499StatusCodeExampleTitle": "400-499 のすべてのステータスコードを検索", "kbn.discover.noResults.searchExamples.400to499StatusCodeWithPhpExtensionExampleTitle": "400-499 の php 拡張子のステータスコードを検索", "kbn.discover.noResults.searchExamples.400to499StatusCodeWithPhpOrHtmlExtensionExampleTitle": "400-499 の php または html 拡張子のステータスコードを検索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 872ec3b71ec10a..2bf6d0db04a80e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1116,7 +1116,6 @@ "kbn.discover.noResults.indexFailureIndexText": "索引 {failureIndex}", "kbn.discover.noResults.indexFailureShardText": "{index},分片 {failureShard}", "kbn.discover.noResults.queryMayNotMatchTitle": "您正在查看的一个或多个索引包含日期字段。您的查询在当前时间范围内可能不匹配任何数据,也可能在当前选定的时间范围内没有任何数据。您可以尝试将时间范围更改为包含数据的时间范围。", - "kbn.discover.noResults.requiredPluginIsNotInstalledOrDisabledTitle": "基于 {unsupportedType} 索引的索引模式需要 X-Pack 的 {unsupportedType} 插件,但其未安装或已禁用", "kbn.discover.noResults.searchExamples.400to499StatusCodeExampleTitle": "查找所有介于 400-499 之间的状态代码", "kbn.discover.noResults.searchExamples.400to499StatusCodeWithPhpExtensionExampleTitle": "查找状态代码 400-499 以及扩展名 php", "kbn.discover.noResults.searchExamples.400to499StatusCodeWithPhpOrHtmlExtensionExampleTitle": "查找状态代码 400-499 以及扩展名 php 或 html", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 2625768dc72421..64069009f6589c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -63,7 +63,7 @@ describe('alert_details', () => { ).containsMatchingElement(

- {alert.name} + {alert.name} = ({ -

- {alert.name} +

+ {alert.name} { const alertState = mockAlertState(); const instances: AlertInstanceListItem[] = [ - alertInstanceToListItem(alert, 'first_instance', alertState.alertInstances!.first_instance), - alertInstanceToListItem(alert, 'second_instance', alertState.alertInstances!.second_instance), + alertInstanceToListItem( + fakeNow.getTime(), + alert, + 'first_instance', + alertState.alertInstances!.first_instance + ), + alertInstanceToListItem( + fakeNow.getTime(), + alert, + 'second_instance', + alertState.alertInstances!.second_instance + ), ]; expect( @@ -48,6 +58,24 @@ describe('alert_instances', () => { ).toEqual(instances); }); + it('render a hidden field with duration epoch', () => { + const alert = mockAlert(); + const alertState = mockAlertState(); + + expect( + shallow( + + ) + .find('[name="alertInstancesDurationEpoch"]') + .prop('value') + ).toEqual(fake2MinutesAgo.getTime()); + }); + it('render all active alert instances', () => { const alert = mockAlert(); const instances = { @@ -75,8 +103,8 @@ describe('alert_instances', () => { .find(EuiBasicTable) .prop('items') ).toEqual([ - alertInstanceToListItem(alert, 'us-central', instances['us-central']), - alertInstanceToListItem(alert, 'us-east', instances['us-east']), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-central', instances['us-central']), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east', instances['us-east']), ]); }); @@ -98,8 +126,8 @@ describe('alert_instances', () => { .find(EuiBasicTable) .prop('items') ).toEqual([ - alertInstanceToListItem(alert, 'us-west'), - alertInstanceToListItem(alert, 'us-east'), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-west'), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east'), ]); }); }); @@ -117,7 +145,7 @@ describe('alertInstanceToListItem', () => { }, }; - expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', status: { label: 'Active', healthColor: 'primary' }, start, @@ -140,7 +168,7 @@ describe('alertInstanceToListItem', () => { }, }; - expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', status: { label: 'Active', healthColor: 'primary' }, start, @@ -153,7 +181,7 @@ describe('alertInstanceToListItem', () => { const alert = mockAlert(); const instance: RawAlertInstance = {}; - expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', status: { label: 'Active', healthColor: 'primary' }, start: undefined, @@ -168,7 +196,7 @@ describe('alertInstanceToListItem', () => { meta: {}, }; - expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', status: { label: 'Active', healthColor: 'primary' }, start: undefined, @@ -181,7 +209,7 @@ describe('alertInstanceToListItem', () => { const alert = mockAlert({ mutedInstanceIds: ['id'], }); - expect(alertInstanceToListItem(alert, 'id')).toEqual({ + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id')).toEqual({ instance: 'id', status: { label: 'Inactive', healthColor: 'subdued' }, start: undefined, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx index 98aa981f40d116..fa4d8f66cd7bf0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -23,6 +23,7 @@ type AlertInstancesProps = { alert: Alert; alertState: AlertTaskState; requestRefresh: () => Promise; + durationEpoch?: number; } & Pick; export const alertInstancesTableColumns = ( @@ -134,6 +135,7 @@ export function AlertInstances({ muteAlertInstance, unmuteAlertInstance, requestRefresh, + durationEpoch = Date.now(), }: AlertInstancesProps) { const [pagination, setPagination] = useState({ index: 0, @@ -142,10 +144,10 @@ export function AlertInstances({ const mergedAlertInstances = [ ...Object.entries(alertInstances).map(([instanceId, instance]) => - alertInstanceToListItem(alert, instanceId, instance) + alertInstanceToListItem(durationEpoch, alert, instanceId, instance) ), ...difference(alert.mutedInstanceIds, Object.keys(alertInstances)).map(instanceId => - alertInstanceToListItem(alert, instanceId) + alertInstanceToListItem(durationEpoch, alert, instanceId) ), ]; const pageOfAlertInstances = getPage(mergedAlertInstances, pagination); @@ -158,25 +160,33 @@ export function AlertInstances({ }; return ( - { - setPagination(changedPage); - }} - rowProps={() => ({ - 'data-test-subj': 'alert-instance-row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - columns={alertInstancesTableColumns(onMuteAction)} - data-test-subj="alertInstancesList" - /> + + + { + setPagination(changedPage); + }} + rowProps={() => ({ + 'data-test-subj': 'alert-instance-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + columns={alertInstancesTableColumns(onMuteAction)} + data-test-subj="alertInstancesList" + /> + ); } export const AlertInstancesWithApi = withBulkAlertOperations(AlertInstances); @@ -207,9 +217,11 @@ const INACTIVE_LABEL = i18n.translate( { defaultMessage: 'Inactive' } ); -const durationSince = (start?: Date) => (start ? Date.now() - start.getTime() : 0); +const durationSince = (durationEpoch: number, startTime?: number) => + startTime ? durationEpoch - startTime : 0; export function alertInstanceToListItem( + durationEpoch: number, alert: Alert, instanceId: string, instance?: RawAlertInstance @@ -221,7 +233,10 @@ export function alertInstanceToListItem( ? { label: ACTIVE_LABEL, healthColor: 'primary' } : { label: INACTIVE_LABEL, healthColor: 'subdued' }, start: instance?.meta?.lastScheduledActions?.date, - duration: durationSince(instance?.meta?.lastScheduledActions?.date), + duration: durationSince( + durationEpoch, + instance?.meta?.lastScheduledActions?.date?.getTime() ?? 0 + ), isMuted, }; } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index a872edfc171352..aeec07aba906c3 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -29,7 +29,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map(service => getExternalServiceSimulatorPath(service) ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v1/table/incident`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); return allPaths; } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts index f215b63560339b..3f1a0952389399 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts @@ -9,8 +9,10 @@ import Hapi from 'hapi'; interface ServiceNowRequest extends Hapi.Request { payload: { - comments: string; - short_description: string; + caseId: string; + title?: string; + description?: string; + comments?: Array<{ commentId: string; version: string; comment: string }>; }; } export function initPlugin(server: Hapi.Server, path: string) { @@ -22,8 +24,16 @@ export function initPlugin(server: Hapi.Server, path: string) { validate: { options: { abortEarly: false }, payload: Joi.object().keys({ - comments: Joi.string(), - short_description: Joi.string(), + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), }), }, }, @@ -32,14 +42,46 @@ export function initPlugin(server: Hapi.Server, path: string) { server.route({ method: 'POST', - path: `${path}/api/now/v1/table/incident`, + path: `${path}/api/now/v2/table/incident`, options: { auth: false, validate: { options: { abortEarly: false }, payload: Joi.object().keys({ - comments: Joi.string(), - short_description: Joi.string(), + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), + }), + }, + }, + handler: servicenowHandler, + }); + + server.route({ + method: 'PATCH', + path: `${path}/api/now/v2/table/incident`, + options: { + auth: false, + validate: { + options: { abortEarly: false }, + payload: Joi.object().keys({ + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), }), }, }, @@ -51,61 +93,9 @@ export function initPlugin(server: Hapi.Server, path: string) { // more info. function servicenowHandler(request: ServiceNowRequest, h: any) { - const body = request.payload; - const text = body && body.short_description; - if (text == null) { - return jsonResponse(h, 400, 'bad request to servicenow simulator'); - } - - switch (text) { - case 'success': - return jsonResponse(h, 200, 'Success'); - - case 'created': - return jsonResponse(h, 201, 'Created'); - - case 'no_text': - return jsonResponse(h, 204, 'Success'); - - case 'invalid_payload': - return jsonResponse(h, 400, 'Bad Request'); - - case 'unauthorized': - return jsonResponse(h, 401, 'Unauthorized'); - - case 'forbidden': - return jsonResponse(h, 403, 'Forbidden'); - - case 'not_found': - return jsonResponse(h, 404, 'Not found'); - - case 'not_allowed': - return jsonResponse(h, 405, 'Method not allowed'); - - case 'not_acceptable': - return jsonResponse(h, 406, 'Not acceptable'); - - case 'unsupported': - return jsonResponse(h, 415, 'Unsupported media type'); - - case 'status_500': - return jsonResponse(h, 500, 'simulated servicenow 500 response'); - - case 'rate_limit': - const response = { - retry_after: 1, - ok: false, - error: 'rate_limited', - }; - - return h - .response(response) - .type('application/json') - .header('retry-after', '1') - .code(429); - } - - return jsonResponse(h, 400, 'unknown request to servicenow simulator'); + return jsonResponse(h, 200, { + result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, + }); } function jsonResponse(h: any, code: number, object?: any) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 15662649266ae8..63c118966cfaef 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -13,26 +13,60 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions'; -// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts +// node ../scripts/functional_test_runner.js --grep "servicenow" --config=test/alerting_api_integration/security_and_spaces/config.ts + +const mapping = [ + { + source: 'title', + target: 'description', + actionType: 'nothing', + }, + { + source: 'description', + target: 'short_description', + actionType: 'nothing', + }, + { + source: 'comments', + target: 'comments', + actionType: 'nothing', + }, +]; // eslint-disable-next-line import/no-default-export export default function servicenowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', + casesConfiguration: { mapping: [...mapping] }, }, secrets: { password: 'elastic', username: 'changeme', }, params: { - comments: 'hello cool service now incident', - short_description: 'this is a cool service now incident', + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + title: 'A title', + description: 'A description', + comments: [ + { + commentId: '123', + version: 'WzU3LDFd', + comment: 'A comment', + }, + { + commentId: '456', + version: 'WzU5LVFd', + comment: 'Another comment', + }, + ], }, }; + describe('servicenow', () => { let simulatedActionId = ''; let servicenowSimulatorURL: string = ''; @@ -55,8 +89,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, - secrets: mockServiceNow.secrets, + secrets: { ...mockServiceNow.secrets }, }) .expect(200); @@ -66,6 +101,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }); @@ -81,11 +117,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with no webhookUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { await supertest .post('/api/action') .set('kbn-xsrf', 'foo') @@ -105,7 +142,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with a non whitelisted webhookUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with a non whitelisted apiUrl', async () => { await supertest .post('/api/action') .set('kbn-xsrf', 'foo') @@ -114,7 +151,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: 'http://servicenow.mynonexistent.com', + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, + secrets: { ...mockServiceNow.secrets }, }) .expect(400) .then((resp: any) => { @@ -136,6 +175,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }) .expect(400) @@ -149,123 +189,127 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - it('should create our servicenow simulator action successfully', async () => { - const { body: createdSimulatedAction } = await supertest + it('should respond with a 400 Bad Request when creating a servicenow action without casesConfiguration', async () => { + await supertest .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow simulator', + name: 'A servicenow action', actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, }, - secrets: mockServiceNow.secrets, - }) - .expect(200); - - simulatedActionId = createdSimulatedAction.id; - }); - - it('should handle executing with a simulated success', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - comments: 'success', - short_description: 'success', - }, + secrets: { ...mockServiceNow.secrets }, }) - .expect(200); - - expect(result.status).to.eql('ok'); - }); - - it('should handle executing with a simulated success without comments', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - short_description: 'success', - }, - }) - .expect(200); - - expect(result.status).to.eql('ok'); + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', + }); + }); }); - it('should handle failing with a simulated success without short_description', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with empty mapping', async () => { await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'success', + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { mapping: [] }, }, + secrets: { ...mockServiceNow.secrets }, }) + .expect(400) .then((resp: any) => { expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, + statusCode: 400, + error: 'Bad Request', message: - 'error validating action params: [short_description]: expected value of type [string] but got [undefined]', + 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', }); }); }); - it('should handle a 40x servicenow error', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + it('should respond with a 400 Bad Request when creating a servicenow action with wrong actionType', async () => { + await supertest + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'invalid_payload', - short_description: 'invalid_payload', + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'description', + actionType: 'non-supported', + }, + ], + }, }, + secrets: { ...mockServiceNow.secrets }, }) - .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.match(/error posting servicenow event: unexpected status 400/); + .expect(400); }); - it('should handle a 429 servicenow error', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + it('should create our servicenow simulator action successfully', async () => { + const { body: createdSimulatedAction } = await supertest + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'rate_limit', - short_description: 'rate_limit', + name: 'A servicenow simulator', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, + secrets: { ...mockServiceNow.secrets }, }) .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.equal( - 'error posting servicenow event: http status 429, retry later' - ); - expect(result.retry).to.equal(true); + simulatedActionId = createdSimulatedAction.id; }); - it('should handle a 500 servicenow error', async () => { + it('should handle executing with a simulated success', async () => { const { body: result } = await supertest .post(`/api/action/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'status_500', - short_description: 'status_500', - }, + params: { caseId: 'success' }, }) .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.equal( - 'error posting servicenow event: http status 500, retry later' - ); - expect(result.retry).to.equal(true); + expect(result).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z' }, + }); + }); + + it('should handle failing with a simulated success without caseId', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [caseId]: expected value of type [string] but got [undefined]', + }); + }); }); }); } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 86fc3d6cd6a6c0..74a267c6e0a8e0 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -18,8 +18,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const alerting = getService('alerting'); const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/57426 - describe.skip('Alert Details', function() { + describe('Alert Details', function() { describe('Header', function() { const testRunUuid = uuid.v4(); before(async () => { @@ -206,8 +205,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the active alert instances', async () => { - const testBeganAt = moment().utc(); - // Verify content await testSubjects.existOrFail('alertInstancesList'); @@ -219,30 +216,42 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { meta: { lastScheduledActions: { date }, }, - }) => moment(date).utc() + }) => date ); + log.debug(`API RESULT: ${JSON.stringify(dateOnAllInstances)}`); + const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); expect(instancesList.map(instance => omit(instance, 'duration'))).to.eql([ { instance: 'us-central', status: 'Active', - start: dateOnAllInstances['us-central'].format('D MMM YYYY @ HH:mm:ss'), + start: moment(dateOnAllInstances['us-central']) + .utc() + .format('D MMM YYYY @ HH:mm:ss'), }, { instance: 'us-east', status: 'Active', - start: dateOnAllInstances['us-east'].format('D MMM YYYY @ HH:mm:ss'), + start: moment(dateOnAllInstances['us-east']) + .utc() + .format('D MMM YYYY @ HH:mm:ss'), }, { instance: 'us-west', status: 'Active', - start: dateOnAllInstances['us-west'].format('D MMM YYYY @ HH:mm:ss'), + start: moment(dateOnAllInstances['us-west']) + .utc() + .format('D MMM YYYY @ HH:mm:ss'), }, ]); + const durationEpoch = moment( + await pageObjects.alertDetailsUI.getAlertInstanceDurationEpoch() + ).utc(); + const durationFromInstanceTillPageLoad = mapValues(dateOnAllInstances, date => - moment.duration(testBeganAt.diff(moment(date).utc())) + moment.duration(durationEpoch.diff(moment(date).utc())) ); instancesList .map(alertInstance => ({ diff --git a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts index 900fe3237ffac0..ddd88cb8885342 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts @@ -54,6 +54,12 @@ export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { }; }); }, + async getAlertInstanceDurationEpoch(): Promise { + const alertInstancesDurationEpoch = await find.byCssSelector( + 'input[data-test-subj="alertInstancesDurationEpoch"]' + ); + return parseInt(await alertInstancesDurationEpoch.getAttribute('value'), 10); + }, async clickAlertInstanceMuteButton(instance: string) { const muteAlertInstanceButton = await testSubjects.find( `muteAlertInstanceButton_${instance}`