diff --git a/NOTICE.txt b/NOTICE.txt index 2341a478cbda9..4eec329b7a603 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -261,33 +261,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- -This product bundles childnode-remove which is available under a -"MIT" license. - -The MIT License (MIT) - -Copyright (c) 2016-present, jszhou -https://github.com/jserz/js_piece - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --- This product bundles code based on probot-metadata@1.0.0 which is available under a "MIT" license. diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index c1e727b1eac65..3827cb6e9aa7d 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -14,10 +14,14 @@ Theses files must be updated when upgrading Node.js: - {kib-repo}blob/{branch}/.node-version[`.node-version`] - {kib-repo}blob/{branch}/.nvmrc[`.nvmrc`] - {kib-repo}blob/{branch}/package.json[`package.json`] - The version is specified in the `engines.node` field. + - {kib-repo}blob/{branch}/WORKSPACE.bazel[`WORKSPACE.bazel`] - The version is specified in the `node_version` property. + Besides this property, the list of files under `node_repositories` must be updated along with their respective SHA256 hashes. + These can be found on the https://nodejs.org[nodejs.org] website. + Example for Node.js v14.16.1: https://nodejs.org/dist/v14.16.1/SHASUMS256.txt.asc -See PR {kib-repo}pull/86593[#86593] for an example of how the Node.js version has been upgraded previously. +See PR {kib-repo}pull/96382[#96382] for an example of how the Node.js version has been upgraded previously. -In the 6.8 branch, the `.ci/Dockerfile` file does not exist, so when upgrading Node.js in that branch, just skip that file. +In the 6.8 branch, neither the `.ci/Dockerfile` file nor the `WORKSPACE.bazel` file exists, so when upgrading Node.js in that branch, just skip those files. === Backporting diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 691d7fb82f3bc..0c40c2a8c4db9 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -485,6 +485,10 @@ Elastic. |Welcome to the Kibana rollup plugin! This plugin provides Kibana support for Elasticsearch's rollup feature. Please refer to the Elasticsearch documentation to understand rollup indices and how to create rollup jobs. +|{kib-repo}blob/{branch}/x-pack/plugins/rule_registry/README.md[ruleRegistry] +|The rule registry plugin aims to make it easy for rule type producers to have their rules produce the data that they need to build rich experiences on top of a unified experience, without the risk of mapping conflicts. + + |{kib-repo}blob/{branch}/x-pack/plugins/runtime_fields/README.md[runtimeFields] |Welcome to the home of the runtime field editor and everything related to runtime fields! diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md new file mode 100644 index 0000000000000..a5694ea2d1af9 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md) + +## AutoRefreshDoneFn type + +Signature: + +```typescript +export declare type AutoRefreshDoneFn = () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md index 80f4832ba5643..5cfd5e1bc9929 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md @@ -12,6 +12,5 @@ export declare type IndexPatternSelectProps = Required void; - maxIndexPatterns?: number; }; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md index 2473c9cfdde8d..cc0cb538be611 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md @@ -19,6 +19,7 @@ export interface ISearchOptions | [isRestore](./kibana-plugin-plugins-data-public.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-public.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-public.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | +| [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-public.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-public.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md new file mode 100644 index 0000000000000..b4431b9467b71 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) + +## ISearchOptions.requestResponder property + +Signature: + +```typescript +requestResponder?: RequestResponder; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md index 4d75dda61d5c9..521ceeb1e37f2 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md @@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES | HISTOGRAM | "histogram" | | | IP | "ip" | | | IP\_RANGE | "ip_range" | | +| MISSING | "missing" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | | NUMBER | "number" | | 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 d2e7ef9db05e8..4429f45f55645 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 @@ -47,6 +47,7 @@ | [getSearchParamsFromRequest(searchRequest, dependencies)](./kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md) | | | [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | | +| [waitUntilNextSessionCompletes$(sessionService, { waitForIdle })](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes. | ## Interfaces @@ -92,6 +93,7 @@ | [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) | | | [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchsessioninfoprovider.md) | Provide info about current search session to be stored in the Search Session saved object | | [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | search source fields | +| [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) | Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | ## Variables @@ -141,6 +143,7 @@ | [AggParam](./kibana-plugin-plugins-data-public.aggparam.md) | | | [AggsStart](./kibana-plugin-plugins-data-public.aggsstart.md) | AggsStart represents the actual external contract as AggsCommonStart is only used internally. The difference is that AggsStart includes the typings for the registry with initialized agg types. | | [AutocompleteStart](./kibana-plugin-plugins-data-public.autocompletestart.md) | \* | +| [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md) | | | [CustomFilter](./kibana-plugin-plugins-data-public.customfilter.md) | | | [EsaggsExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esaggsexpressionfunctiondefinition.md) | | | [EsdslExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md index cfaad01c029ea..259009c1c5668 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md @@ -53,7 +53,6 @@ search: { timeRange: import("../common").TimeRange | undefined; } | undefined; }; - getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; tabifyAggResponse: typeof tabifyAggResponse; tabifyGetColumns: typeof tabifyGetColumns; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 7c7f2a53aca92..193a2e5a24f3f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "isClearable" | "isInvalid" | "storageKey" | "refreshInterval" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "disableLanguageSwitcher" | "autoSubmit" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "isClearable" | "refreshInterval" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md index cc50d3f017971..d384b9659dbcd 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md @@ -9,9 +9,9 @@ Returns body contents of the search request, often referred as query DSL. Signature: ```typescript -getSearchRequestBody(): Promise; +getSearchRequestBody(): any; ``` Returns: -`Promise` +`any` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md new file mode 100644 index 0000000000000..a4b294fb1decd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [waitUntilNextSessionCompletes$](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) + +## waitUntilNextSessionCompletes$() function + +Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes. + +Signature: + +```typescript +export declare function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| sessionService | ISessionService | [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) | +| { waitForIdle } | WaitUntilNextSessionCompletesOptions | | + +Returns: + +`import("rxjs").Observable` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md new file mode 100644 index 0000000000000..d575722a22453 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) + +## WaitUntilNextSessionCompletesOptions interface + +Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) + +Signature: + +```typescript +export interface WaitUntilNextSessionCompletesOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md) | number | For how long to wait between session state transitions before considering that session completed | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md new file mode 100644 index 0000000000000..60d3df7783852 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) > [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md) + +## WaitUntilNextSessionCompletesOptions.waitForIdle property + +For how long to wait between session state transitions before considering that session completed + +Signature: + +```typescript +waitForIdle?: number; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 7fd4dd5b8e566..413a59be3d427 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -19,6 +19,7 @@ export interface ISearchOptions | [isRestore](./kibana-plugin-plugins-data-server.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-server.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-server.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | +| [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-server.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md new file mode 100644 index 0000000000000..7440f5a9d26cf --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) + +## ISearchOptions.requestResponder property + +Signature: + +```typescript +requestResponder?: RequestResponder; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md index be4c3705bd8de..40fa872ff0fc6 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md @@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES | HISTOGRAM | "histogram" | | | IP | "ip" | | | IP\_RANGE | "ip_range" | | +| MISSING | "missing" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | | NUMBER | "number" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md index 0911c3e86964d..930f7710f9a00 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md @@ -36,8 +36,6 @@ search: { toAbsoluteDates: typeof toAbsoluteDates; calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; - getRequestInspectorStats: typeof getRequestInspectorStats; - getResponseInspectorStats: typeof getResponseInspectorStats; tabifyAggResponse: typeof tabifyAggResponse; tabifyGetColumns: typeof tabifyGetColumns; } diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md index 5201444e69867..290dc10662569 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md @@ -9,9 +9,9 @@ Merges input$ and output$ streams and debounces emit till next macro-task. Could Signature: ```typescript -getUpdated$(): Readonly>; +getUpdated$(): Readonly>; ``` Returns: -`Readonly>` +`Readonly>` diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index 1bdc9b9dea859..5e6a60f019bea 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -12,7 +12,7 @@ When you've finished, you'll know how to: [float] === Required privileges When security is enabled, you must have `read`, `write`, and `manage` privileges on the `kibana_sample_data_*` indices. -For more information, refer to {ref}/security-privileges.html[Security privileges]. +Learn how to <>, or refer to {ref}/security-privileges.html[Security privileges] for more information. [float] [[set-up-on-cloud]] @@ -141,3 +141,5 @@ For more information, refer to <>. If you are you ready to add your own data, refer to <>. If you want to ingest your data, refer to {fleet-guide}/fleet-quick-start.html[Quick start: Get logs and metrics into the Elastic Stack]. + +If you want to secure access to your data, refer to our guide on <> diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index a9de1888465f7..02cb25078cc92 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -119,8 +119,12 @@ When date histograms use the `auto` interval, Kibana attempts to generate this number of bars. [[histogram-maxbars]]`histogram:maxBars`:: -Date histograms are not generated with more bars than the value of this property, -scaling values when necessary. +To improve performance, limits the density of date and number histograms across {kib} +using a test query. When the test query contains too many buckets, +the interval between buckets increases. This setting applies separately +to each histogram aggregation, and does not apply to other types of aggregations. +To find the maximum value of this setting, divide the {es} `search.max_buckets` +value by the maximum number of aggregations in each visualization. [[history-limit]]`history:limit`:: In fields that have history, such as query inputs, show this many recent values. @@ -134,9 +138,7 @@ Fields that exist outside of `_source`. Kibana merges these fields into the document when displaying it. [[metrics-maxbuckets]]`metrics:max_buckets`:: -The maximum numbers of buckets that a single data source can return. This might -arise when the user selects a short interval (for example, 1s) for a long time -period (1 year). +Affects the *TSVB* histogram density. Must be set higher than `histogram:maxBars`. [[query-allowleadingwildcards]]`query:allowLeadingWildcards`:: Allows a wildcard (*) as the first character in a query clause. Only applies diff --git a/docs/maps/trouble-shooting.asciidoc b/docs/maps/trouble-shooting.asciidoc index 1c53fbd55ea4b..11aa636e0d852 100644 --- a/docs/maps/trouble-shooting.asciidoc +++ b/docs/maps/trouble-shooting.asciidoc @@ -44,10 +44,13 @@ Increase <> for large index patterns. * Ensure fill color and border color are distinguishable from map tiles. It's hard to see white features on a white background. [float] -==== Tiles are not displayed +==== Elastic Maps Service basemaps are not displayed +*Maps* uses tile and vector data from Elastic Maps Service by default. See <> for more info. -* Ensure your tile server has configured https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[Cross-Origin Resource Sharing (CORS)] so tile requests from your Kibana domain have permission to access your tile server domain. -* Ensure tiles have the required coordinate system. Vector data must use EPSG:4326 and tiles must use EPSG:3857. +[float] +==== Custom tiles are not displayed +* When using a custom tile service, ensure your tile server has configured https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[Cross-Origin Resource Sharing (CORS)] so tile requests from your {kib} domain have permission to access your tile server domain. +* Ensure custom vector and tile services have the required coordinate system. Vector data must use EPSG:4326 and tiles must use EPSG:3857. [float] ==== Coordinate and region map visualizations not available in New Visualization menu diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index e4d2b53a2d8d6..5d0242ae31950 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -286,3 +286,9 @@ This content has moved. See {ref}/ingest.html[Ingest pipelines]. == Timelion This content has moved. refer to <>. + + +[role="exclude",id="space-rbac-tutorial"] +== Tutorial: Use role-based access control to customize Kibana spaces + +This content has moved. refer to <>. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 20bbbcf874c05..c748d63484e28 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -53,8 +53,12 @@ You can configure the following settings in the `kibana.yml` file. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. +| `xpack.actions` +`.preconfiguredAlertHistoryEsIndex` {ess-icon} + | Enables a preconfigured alert history {es} <> connector. Defaults to `false`. + | `xpack.actions.preconfigured` - | Specifies preconfigured action IDs and configs. Defaults to {}. + | Specifies preconfigured connector IDs and configs. Defaults to {}. | `xpack.actions.proxyUrl` {ess-icon} | Specifies the proxy URL to use, if using a proxy for actions. By default, no proxy is used. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 643718b961650..90e813afad6f4 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -429,6 +429,15 @@ to display map tiles in tilemap visualizations. By default, override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` +| `migrations.batchSize:` + | Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If the migration fails due to a `circuit_breaking_exception`, set a smaller `batchSize` value. *Default: `1000`* + +| `migrations.enableV2:` + | experimental[]. Enables the new Saved Objects migration algorithm. For information about the migration algorithm, refer to <>. When `migrations v2` is stable, the setting will be removed in an upcoming release without any further notice. Setting the value to `false` causes {kib} to use the legacy migration algorithm, which shipped in 7.11 and earlier versions. *Default: `true`* + +| `migrations.retryAttempts:` + | The number of times migrations retry temporary failures, such as a network timeout, 503 status code, or `snapshot_in_progress_exception`. When upgrade migrations frequently fail after exhausting all retry attempts with a message such as `Unable to complete the [...] step after 15 attempts, terminating.`, increase the setting value. *Default: `15`* + | `newsfeed.enabled:` | Controls whether to enable the newsfeed system for the {kib} UI notification center. Set to `false` to disable the diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 80226e737e9c0..e23dcbf298fd5 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -82,3 +82,38 @@ PUT test } } -------------------------------------------------- + +[float] +[[preconfigured-connector-alert-history]] +=== Alert history {es} index connector + +experimental[] {kib} offers a preconfigured index connector to facilitate indexing active alert data into {es}. + +[WARNING] +================================================== +This functionality is experimental and may be changed or removed completely in a future release. +================================================== + +To use this connector, set the <> configuration to `true`. + +```js + xpack.actions.preconfiguredAlertHistoryEsIndex: true +``` + +When creating a new rule, add an <> and select the `Alert history Elasticsearch index (preconfigured)` connector. + +[role="screenshot"] +image::images/pre-configured-alert-history-connector.png[Select pre-configured alert history connectors] + +Documents are indexed using a preconfigured schema that captures the <> available for the rule. By default, these documents are indexed into the `kibana-alert-history-default` index, but you can specify a different index. Index names must start with `kibana-alert-history-` to take advantage of the preconfigured alert history index template. + +[IMPORTANT] +============================================== +To write documents to the preconfigured index, you must have `all` or `write` privileges to the `kibana-alert-history-*` indices. Refer to <> for more information. +============================================== + +[NOTE] +================================================== +The `kibana-alert-history-*` indices are not configured to use ILM so they must be maintained manually. If the index size grows large, +consider using the {ref}/docs-delete-by-query.html[delete by query] API to clean up older documents in the index. +================================================== \ No newline at end of file diff --git a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc index ee8a28a864824..557404f24288a 100644 --- a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc +++ b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc @@ -51,6 +51,14 @@ two out-of-the box connectors: <> and <>. ============================================== +[float] +[[build-in-preconfigured-connectors]] +==== Built-in preconfigured connectors + +{kib} provides one built-in preconfigured connector: + +* <> + [float] [[managing-pre-configured-connectors]] ==== View preconfigured connectors @@ -63,4 +71,4 @@ image::images/pre-configured-connectors-managing.png[Connectors managing tab wit Clicking a preconfigured connector shows the description, but not the configuration. A message indicates that this is a preconfigured connector. [role="screenshot"] -image::images/pre-configured-connectors-view-screen.png[Pre-configured connector view details] +image::images/pre-configured-connectors-view-screen.png[Pre-configured connector view details] \ No newline at end of file diff --git a/docs/user/alerting/images/pre-configured-alert-history-connector.png b/docs/user/alerting/images/pre-configured-alert-history-connector.png new file mode 100644 index 0000000000000..35f9b19710cda Binary files /dev/null and b/docs/user/alerting/images/pre-configured-alert-history-connector.png differ diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index a4acc93310e5d..805ae924a599e 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -292,7 +292,11 @@ xpack.security.authc.providers: order: 1 ----------------------------------------------- -Kibana uses SPNEGO, which wraps the Kerberos protocol for use with HTTP, extending it to web applications. At the end of the Kerberos handshake, Kibana will forward the service ticket to Elasticsearch. Elasticsearch will unpack it and it will respond with an access and refresh token which are then used for subsequent authentication. +IMPORTANT: {kib} uses SPNEGO, which wraps the Kerberos protocol for use with HTTP, extending it to web applications. +At the end of the Kerberos handshake, {kib} forwards the service ticket to {es}, then {es} unpacks the service ticket and responds with an access and refresh token, which are used for subsequent authentication. +On every {es} node that {kib} connects to, the keytab file should always contain the HTTP service principal for the {kib} host. +The HTTP service principal name must have the `HTTP/kibana.domain.local@KIBANA.DOMAIN.LOCAL` format. + [[anonymous-authentication]] ==== Anonymous authentication diff --git a/docs/user/security/images/role-index-privilege.png b/docs/user/security/images/role-index-privilege.png deleted file mode 100644 index 1dc1ae640e3ba..0000000000000 Binary files a/docs/user/security/images/role-index-privilege.png and /dev/null differ diff --git a/docs/user/security/images/role-management.png b/docs/user/security/images/role-management.png deleted file mode 100644 index 29efdd85c4df3..0000000000000 Binary files a/docs/user/security/images/role-management.png and /dev/null differ diff --git a/docs/user/security/images/role-new-user.png b/docs/user/security/images/role-new-user.png deleted file mode 100644 index c882eeea42d60..0000000000000 Binary files a/docs/user/security/images/role-new-user.png and /dev/null differ diff --git a/docs/user/security/images/role-space-visualization.png b/docs/user/security/images/role-space-visualization.png deleted file mode 100644 index 36f83f09f064b..0000000000000 Binary files a/docs/user/security/images/role-space-visualization.png and /dev/null differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-role.png b/docs/user/security/images/tutorial-secure-access-example-1-role.png new file mode 100644 index 0000000000000..53540da7170ea Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-role.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-space.png b/docs/user/security/images/tutorial-secure-access-example-1-space.png new file mode 100644 index 0000000000000..a48fdeaa6efa1 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-space.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-test.png b/docs/user/security/images/tutorial-secure-access-example-1-test.png new file mode 100644 index 0000000000000..305b97017a9d8 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-test.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-user.png b/docs/user/security/images/tutorial-secure-access-example-1-user.png new file mode 100644 index 0000000000000..8df26cf28ef16 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-user.png differ diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index 6a5c4a83aa3ad..71c5bd268a67d 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -47,4 +47,3 @@ include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] include::encryption-keys/index.asciidoc[] include::role-mappings/index.asciidoc[] -include::rbac_tutorial.asciidoc[] diff --git a/docs/user/security/rbac_tutorial.asciidoc b/docs/user/security/rbac_tutorial.asciidoc deleted file mode 100644 index 6324539c3c10a..0000000000000 --- a/docs/user/security/rbac_tutorial.asciidoc +++ /dev/null @@ -1,105 +0,0 @@ -[[space-rbac-tutorial]] -=== Tutorial: Use role-based access control to customize Kibana spaces - -With role-based access control (RBAC), you can provide users access to data, tools, -and Kibana spaces. In this tutorial, you will learn how to configure roles -that provide the right users with the right access to the data, tools, and -Kibana spaces. - -[float] -==== Scenario - -Our user is a web developer working on a bank's -online mortgage service. The web developer has these -three requirements: - -* Have access to the data for that service -* Build visualizations and dashboards -* Monitor the performance of the system - -You'll provide the web developer with the access and privileges to get the job done. - -[float] -==== Prerequisites - -To complete this tutorial, you'll need the following: - -* **Administrative privileges**: You must have a role that grants privileges to create a space, role, and user. This is any role which grants the `manage_security` cluster privilege. By default, the `superuser` role provides this access. See the {ref}/built-in-roles.html[built-in] roles. -* **A space**: In this tutorial, use `Dev Mortgage` as the space -name. See <> for -details on creating a space. -* **Data**: You can use <> or -live data. In the following steps, Filebeat and Metricbeat data are used. - -[float] -==== Steps - -With the requirements in mind, here are the steps that you will work -through in this tutorial: - -* Create a role named `mortgage-developer` -* Give the role permission to access the data in the relevant indices -* Give the role permission to create visualizations and dashboards -* Create the web developer's user account with the proper roles - -[float] -==== Create a role - -Open the main menu, then click *Stack Management > Roles* -for an overview of your roles. This view provides actions -for you to create, edit, and delete roles. - -[role="screenshot"] -image::security/images/role-management.png["Role management"] - - -You can create as many roles as you like. Click *Create role* and -provide a name. Use `dev-mortgage` because this role is for a developer -working on the bank's mortgage application. - - -[float] -==== Give the role permission to access the data - -Access to data in indices is an index-level privilege, so in -*Index privileges*, add lines for the indices that contain the -data for this role. Two privileges are required: `read` and -`view_index_metadata`. All privileges are detailed in the -https://www.elastic.co/guide/en/elasticsearch/reference/current/security-privileges.html[security privileges] documentation. - -In the screenshots, Filebeat and Metricbeat data is used, but you -should use the index patterns for your indices. - -[role="screenshot"] -image::security/images/role-index-privilege.png["Index privilege"] - -[float] -==== Give the role permissions to {kib} apps - -To enable users to create dashboards, visualizations, and saved searches, add {kib} privileges to the `dev-mortgage` role. - -. On the *{kib} privileges* window, select *Dev Mortgage* from the *Space* dropdown. - -. Click **Add space privilege**. - -. For *Dashboard*, *Visualize Library*, and *Discover*, click *All*. -+ -It is common to create saved searches in *Discover* while creating visualizations. -+ -[role="screenshot"] -image::security/images/role-space-visualization.png["Associate space"] - -[float] -==== Create the developer user account with the proper roles - -. Open the main menu, then click *Stack Management > Users*. -. Click **Create user**, then give the user the `dev-mortgage` -and `monitoring-user` roles, which are required for *Stack Monitoring* users. - -[role="screenshot"] -image::security/images/role-new-user.png["Developer user"] - -Finally, have the developer log in and access the Dev Mortgage space -and create a new visualization. - -NOTE: If the user is assigned to only one space, they will automatically enter that space on login. diff --git a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc new file mode 100644 index 0000000000000..63b83712e3e6e --- /dev/null +++ b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc @@ -0,0 +1,136 @@ +[[tutorial-secure-access-to-kibana]] +== Securing access to {kib} + + +{kib} is home to an ever-growing suite of powerful features, which help you get the most out of your data. Your data is important, and should be protected. {kib} allows you to secure access to your data and control how users are able to interact with your data. + +For example, some users might only need to view your stunning dashboards, while others might need to manage your fleet of Elastic agents and run machine learning jobs to detect anomalous behavior in your network. + +This guide introduces you to three of {kib}'s security features: spaces, roles, and users. By the end of this tutorial, you will learn how to manage these entities, and how you can leverage them to secure access to both {kib} and your data. + +[float] +=== Spaces + +Do you have multiple teams using {kib}? Do you want a “playground” to experiment with new visualizations or alerts? If so, then <> can help. + +Think of a space as another instance of {kib}. A space allows you to organize your <>, <>, <>, and much more into their own categories. For example, you might have a Marketing space for your marketeers to track the results of their campaigns, and an Engineering space for your developers to {apm-get-started-ref}/overview.html[monitor application performance]. + +The assets you create in one space are isolated from other spaces, so when you enter a space, you only see the assets that belong to that space. + +Refer to the <> for more information. + +[float] +=== Roles + +Once your spaces are setup, the next step to securing access is to provision your roles. Roles are a collection of privileges that allow you to perform actions in {kib} and Elasticsearch. Roles are assigned to users, and to {ref}/built-in-users.html[system accounts] that power the Elastic Stack. + +You can create your own roles, or use any of the {ref}/built-in-roles.html[built-in roles]. Some built-in roles are intended for Elastic Stack components and should not be assigned to end users directly. + +One of the more useful built-in roles is `kibana_admin`. Assigning this role to your users will grant access to all of {kib}'s features. This includes the ability to manage Spaces. + +The built-in roles are great for getting started with the Elastic Stack, and for system administrators who do not need more restrictive access. With so many features, it’s not possible to ship more granular roles to accommodate everyone’s needs. This is where custom roles come in. + +As an administrator, you have the ability to create your own roles to describe exactly the kind of access your users should have. For example, you might create a `marketing_user` role, which you then assign to all users in your marketing department. This role would grant access to all of the necessary data and features for this team to be successful, without granting them access they don’t require. + + +[float] +=== Users + +Once your roles are setup, the next step to securing access is to create your users, and assign them one or more roles. {kib}'s user management allows you to provision accounts for each of your users. + +TIP: Want Single Sign-on? {kib} supports a wide range of SSO implementations, including SAML, OIDC, LDAP/AD, and Kerberos. <>. + + +[float] +[[tutorial-secure-kibana-dashboards-only]] +=== Example: Create a user with access only to dashboards + +Let’s work through an example together. Consider a marketing analyst who wants to monitor the effectiveness of their campaigns. They should be able to see their team’s dashboards, but not be allowed to view or manage anything else in {kib}. All of the team’s dashboards are located in the Marketing space. + +[float] +==== Create a space + +Create a Marketing space for your marketing analysts to use. + +. Open the main menu, and select **Stack Management**. +. Under **{kib}**, select **Spaces**. +. Click **Create a space**. +. Give this space a unique name. For example: `Marketing`. +. Click **Create space**. ++ +If you’ve followed the example above, you should end up with a space that looks like this: ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-space.png[Create space UI] + + +[float] +==== Create a role + +To effectively use dashboards, create a role that describes the privileges you want to grant. +In this example, a marketing analyst will need: + +* Access to **read** the data that powers the dashboards +* Access to **read** the dashboards within the `Marketing` space + +To create the role: + +. Open the main menu, and select **Stack Management**. +. Under **Security**, select **Roles**. +. Click **Create role**. +. Give this role a unique name. For example: `marketing_dashboards_role`. +. For this example, you want to store all marketing data in the `acme-marketing-*` set of indices. To grant this access, locate the **Index privileges** section and enter: +.. `acme-marketing-*` in the **Indices** field. +.. `read` and `view_index_metadata` in the **Privileges** field. ++ +TIP: You can add multiple patterns of indices, and grant different access levels to each. Click **Add index privilege** to grant additional access. +. To grant access to dashboards in the `Marketing` space, locate the {kib} section, and click **Add {kib} privilege**: +.. From the **Spaces** dropdown, select the `Marketing` space. +.. Expand the **Analytics** section, and select the **Read** privilege for **Dashboard**. +.. Click **Add Kibana privilege**. +. Click **Create role**. ++ +If you’ve followed the example above, you should end up with a role that looks like this: ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-role.png[Create role UI] + + +[float] +==== Create a user + +Now that you created a role, create a user account. + +. Navigate to *Stack Management*, and under *Security*, select *Users*. +. Click *Create user*. +. Give this user a descriptive username, and choose a secure password. +. Assign the *marketing_dashboards_role* that you previously created to this new user. +. Click *Create user*. + +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-user.png[Create user UI] + +[float] +==== Verify + +Verify that the user and role are working correctly. + +. Logout of {kib} if you are already logged in. +. In the login screen, enter the username and password for the account you created. ++ +You’re taken into the `Marketing` space, and the main navigation shows only the *Dashboard* application. ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-test.png[Verifying access to dashboards] + + +[float] +=== What's next? + +This guide is an introduction to {kib}'s security features. Check out these additional resources to learn more about authenticating and authorizing your users. + +* View the <> to learn more about single-sign on and other login features. + +* View the <> to learn more about authorizing access to {kib}'s features. + +Still have questions? Ask on our https://discuss.elastic.co/c/kibana[Kibana discuss forum] and a fellow community member or Elastic engineer will help out. diff --git a/docs/user/setup.asciidoc b/docs/user/setup.asciidoc index a38bf699c1db8..bea13c1ef49b2 100644 --- a/docs/user/setup.asciidoc +++ b/docs/user/setup.asciidoc @@ -54,6 +54,8 @@ include::{kib-repo-dir}/setup/start-stop.asciidoc[] include::{kib-repo-dir}/setup/access.asciidoc[] +include::security/tutorials/how-to-secure-access-to-kibana.asciidoc[] + include::{kib-repo-dir}/setup/connect-to-elasticsearch.asciidoc[] include::{kib-repo-dir}/setup/upgrade.asciidoc[] diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index c87bf21e0e71c..3bac445581ae7 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -204,8 +204,8 @@ export const SearchExamplesApp = ({ }); } - setRequest(await searchSource.getSearchRequestBody()); - const res = await searchSource.fetch(); + setRequest(searchSource.getSearchRequestBody()); + const res = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; diff --git a/package.json b/package.json index a1acf73ea26f0..9bddca4665467 100644 --- a/package.json +++ b/package.json @@ -131,10 +131,12 @@ "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", + "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", "@kbn/std": "link:packages/kbn-std", "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", @@ -206,7 +208,6 @@ "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", "core-js": "^3.6.5", - "custom-event-polyfill": "^0.3.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", diff --git a/packages/kbn-analytics/tsconfig.json b/packages/kbn-analytics/tsconfig.json index c2e579e7fdbea..80a2255d71805 100644 --- a/packages/kbn-analytics/tsconfig.json +++ b/packages/kbn-analytics/tsconfig.json @@ -7,6 +7,7 @@ "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, + "isolatedModules": true, "sourceMap": true, "sourceRoot": "../../../../../packages/kbn-analytics/src", "types": [ diff --git a/src/core/server/dev/dev_config.ts b/packages/kbn-io-ts-utils/jest.config.js similarity index 56% rename from src/core/server/dev/dev_config.ts rename to packages/kbn-io-ts-utils/jest.config.js index 2fec778d85713..1a71166fae843 100644 --- a/src/core/server/dev/dev_config.ts +++ b/packages/kbn-io-ts-utils/jest.config.js @@ -6,11 +6,8 @@ * Side Public License, v 1. */ -import { schema } from '@kbn/config-schema'; - -export const config = { - path: 'dev', - // dev configuration is validated by the dev cli. - // we only need to register the `dev` schema to avoid failing core's config validation - schema: schema.object({}, { unknowns: 'ignore' }), +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-io-ts-utils'], }; diff --git a/packages/kbn-io-ts-utils/package.json b/packages/kbn-io-ts-utils/package.json new file mode 100644 index 0000000000000..4d6f02d3f85a6 --- /dev/null +++ b/packages/kbn-io-ts-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/io-ts-utils", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + } +} diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts new file mode 100644 index 0000000000000..2032127b1eb91 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { jsonRt } from './json_rt'; +export { mergeRt } from './merge_rt'; +export { strictKeysRt } from './strict_keys_rt'; diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts similarity index 85% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.test.ts index d6c286c672d90..1220639fc7bef 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -12,9 +13,7 @@ import { Right } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; -function getValueOrThrow>( - either: TEither -): Right { +function getValueOrThrow>(either: TEither): Right { const value = pipe( either, fold(() => { diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts b/packages/kbn-io-ts-utils/src/json_rt/index.ts similarity index 74% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.ts index 0207145a17be7..bc596d53db54c 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/merge/index.test.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.test.ts index af5a0221662d5..b25d4451895f2 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts @@ -1,18 +1,19 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { isLeft } from 'fp-ts/lib/Either'; -import { merge } from './'; +import { mergeRt } from '.'; import { jsonRt } from '../json_rt'; describe('merge', () => { it('fails on one or more errors', () => { - const type = merge([t.type({ foo: t.string }), t.type({ bar: t.number })]); + const type = mergeRt(t.type({ foo: t.string }), t.type({ bar: t.number })); const result = type.decode({ foo: '' }); @@ -20,10 +21,7 @@ describe('merge', () => { }); it('merges left to right', () => { - const typeBoolean = merge([ - t.type({ foo: t.string }), - t.type({ foo: jsonRt.pipe(t.boolean) }), - ]); + const typeBoolean = mergeRt(t.type({ foo: t.string }), t.type({ foo: jsonRt.pipe(t.boolean) })); const resultBoolean = typeBoolean.decode({ foo: 'true', @@ -34,10 +32,7 @@ describe('merge', () => { foo: true, }); - const typeString = merge([ - t.type({ foo: jsonRt.pipe(t.boolean) }), - t.type({ foo: t.string }), - ]); + const typeString = mergeRt(t.type({ foo: jsonRt.pipe(t.boolean) }), t.type({ foo: t.string })); const resultString = typeString.decode({ foo: 'true', @@ -50,10 +45,10 @@ describe('merge', () => { }); it('deeply merges values', () => { - const type = merge([ + const type = mergeRt( t.type({ foo: t.type({ baz: t.string }) }), - t.type({ foo: t.type({ bar: t.string }) }), - ]); + t.type({ foo: t.type({ bar: t.string }) }) + ); const result = type.decode({ foo: { diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.ts similarity index 62% rename from x-pack/plugins/apm/common/runtime_types/merge/index.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.ts index 451edf678aabe..c582767fb5101 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.ts @@ -1,31 +1,40 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { merge as lodashMerge } from 'lodash'; import { isLeft } from 'fp-ts/lib/Either'; -import { ValuesType } from 'utility-types'; -export type MergeType< - T extends t.Any[], - U extends ValuesType = ValuesType -> = t.Type & { - _tag: 'MergeType'; - types: T; -}; +type PlainObject = Record; + +type DeepMerge = U extends PlainObject + ? T extends PlainObject + ? Omit & + { + [key in keyof U]: T extends { [k in key]: any } ? DeepMerge : U[key]; + } + : U + : U; // this is similar to t.intersection, but does a deep merge // instead of a shallow merge -export function merge( - types: [A, B] -): MergeType<[A, B]>; +export type MergeType = t.Type< + DeepMerge, t.TypeOf>, + DeepMerge, t.OutputOf> +> & { + _tag: 'MergeType'; + types: [T1, T2]; +}; + +export function mergeRt(a: T1, b: T2): MergeType; -export function merge(types: t.Any[]) { +export function mergeRt(...types: t.Any[]) { const mergeType = new t.Type( 'merge', (u): u is unknown => { diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts similarity index 77% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts index 4212e0430ff5f..ab20ca42a283e 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -14,10 +15,7 @@ describe('strictKeysRt', () => { it('correctly and deeply validates object keys', () => { const checks: Array<{ type: t.Type; passes: any[]; fails: any[] }> = [ { - type: t.intersection([ - t.type({ foo: t.string }), - t.partial({ bar: t.string }), - ]), + type: t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.string })]), passes: [{ foo: '' }, { foo: '', bar: '' }], fails: [ { foo: '', unknownKey: '' }, @@ -26,15 +24,9 @@ describe('strictKeysRt', () => { }, { type: t.type({ - path: t.union([ - t.type({ serviceName: t.string }), - t.type({ transactionType: t.string }), - ]), + path: t.union([t.type({ serviceName: t.string }), t.type({ transactionType: t.string })]), }), - passes: [ - { path: { serviceName: '' } }, - { path: { transactionType: '' } }, - ], + passes: [{ path: { serviceName: '' } }, { path: { transactionType: '' } }], fails: [ { path: { serviceName: '', unknownKey: '' } }, { path: { transactionType: '', unknownKey: '' } }, @@ -62,9 +54,7 @@ describe('strictKeysRt', () => { if (!isRight(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be allowed, but validation failed with ${ + `Expected ${JSON.stringify(value)} to be allowed, but validation failed with ${ result.left[0].message }` ); @@ -76,9 +66,7 @@ describe('strictKeysRt', () => { if (!isLeft(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be disallowed, but validation succeeded` + `Expected ${JSON.stringify(value)} to be disallowed, but validation succeeded` ); } }); diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts index e90ccf7eb8d31..56afdf54463f7 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts @@ -1,14 +1,15 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { either, isRight } from 'fp-ts/lib/Either'; import { mapValues, difference, isPlainObject, forEach } from 'lodash'; -import { MergeType, merge } from '../merge'; +import { MergeType, mergeRt } from '../merge_rt'; /* Type that tracks validated keys, and fails when the input value @@ -21,7 +22,7 @@ type ParsableType = | t.PartialType | t.ExactType | t.InterfaceType - | MergeType; + | MergeType; function getKeysInObject>( object: T, @@ -32,17 +33,16 @@ function getKeysInObject>( const ownPrefix = prefix ? `${prefix}.${key}` : key; keys.push(ownPrefix); if (isPlainObject(object[key])) { - keys.push( - ...getKeysInObject(object[key] as Record, ownPrefix) - ); + keys.push(...getKeysInObject(object[key] as Record, ownPrefix)); } }); return keys; } -function addToContextWhenValidated< - T extends t.InterfaceType | t.PartialType ->(type: T, prefix: string): T { +function addToContextWhenValidated | t.PartialType>( + type: T, + prefix: string +): T { const validate = (input: unknown, context: t.Context) => { const result = type.validate(input, context); const keysType = context[0].type as StrictKeysType; @@ -50,36 +50,19 @@ function addToContextWhenValidated< throw new Error('Expected a top-level StrictKeysType'); } if (isRight(result)) { - keysType.trackedKeys.push( - ...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`) - ); + keysType.trackedKeys.push(...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`)); } return result; }; if (type._tag === 'InterfaceType') { - return new t.InterfaceType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.InterfaceType(type.name, type.is, validate, type.encode, type.props) as T; } - return new t.PartialType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.PartialType(type.name, type.is, validate, type.encode, type.props) as T; } -function trackKeysOfValidatedTypes( - type: ParsableType | t.Any, - prefix: string = '' -): t.Any { +function trackKeysOfValidatedTypes(type: ParsableType | t.Any, prefix: string = ''): t.Any { if (!('_tag' in type)) { return type; } @@ -89,27 +72,24 @@ function trackKeysOfValidatedTypes( case 'IntersectionType': { const collectionType = type as t.IntersectionType; return t.intersection( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'UnionType': { const collectionType = type as t.UnionType; return t.union( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'MergeType': { - const collectionType = type as MergeType; - return merge( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + const collectionType = type as MergeType; + return mergeRt( + ...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [ + t.Any, + t.Any + ]) ); } @@ -142,9 +122,7 @@ function trackKeysOfValidatedTypes( case 'ExactType': { const exactType = type as t.ExactType; - return t.exact( - trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps - ); + return t.exact(trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps); } default: @@ -169,17 +147,11 @@ class StrictKeysType< (input, context) => { this.trackedKeys.length = 0; return either.chain(trackedType.validate(input, context), (i) => { - const originalKeys = getKeysInObject( - input as Record - ); + const originalKeys = getKeysInObject(input as Record); const excessKeys = difference(originalKeys, this.trackedKeys); if (excessKeys.length) { - return t.failure( - i, - context, - `Excess keys are not allowed: \n${excessKeys.join('\n')}` - ); + return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`); } return t.success(i); diff --git a/packages/kbn-io-ts-utils/tsconfig.json b/packages/kbn-io-ts-utils/tsconfig.json new file mode 100644 index 0000000000000..6c67518e21073 --- /dev/null +++ b/packages/kbn-io-ts-utils/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-io-ts-utils/src", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-server-route-repository/README.md b/packages/kbn-server-route-repository/README.md new file mode 100644 index 0000000000000..e22205540ef31 --- /dev/null +++ b/packages/kbn-server-route-repository/README.md @@ -0,0 +1,7 @@ +# @kbn/server-route-repository + +Utility functions for creating a typed server route repository, and a typed client, generating runtime validation and type validation from the same route definition. + +## Usage + +TBD diff --git a/packages/kbn-server-route-repository/jest.config.js b/packages/kbn-server-route-repository/jest.config.js new file mode 100644 index 0000000000000..7449bb7cd3860 --- /dev/null +++ b/packages/kbn-server-route-repository/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-server-route-repository'], +}; diff --git a/packages/kbn-server-route-repository/package.json b/packages/kbn-server-route-repository/package.json new file mode 100644 index 0000000000000..ce1ca02d0c4f6 --- /dev/null +++ b/packages/kbn-server-route-repository/package.json @@ -0,0 +1,16 @@ +{ + "name": "@kbn/server-route-repository", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": { + "@kbn/io-ts-utils": "link:../kbn-io-ts-utils" + } +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_factory.ts b/packages/kbn-server-route-repository/src/create_server_route_factory.ts new file mode 100644 index 0000000000000..edf9bd657f995 --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_factory.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { + ServerRouteCreateOptions, + ServerRouteHandlerResources, + RouteParamsRT, + ServerRoute, +} from './typings'; + +export function createServerRouteFactory< + TRouteHandlerResources extends ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions +>(): < + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined +>( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > +) => ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions +> { + return (route) => route; +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_repository.ts b/packages/kbn-server-route-repository/src/create_server_route_repository.ts new file mode 100644 index 0000000000000..5ac89ebcac77f --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_repository.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { + ServerRouteHandlerResources, + ServerRouteRepository, + ServerRouteCreateOptions, +} from './typings'; + +export function createServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = never, + TRouteCreateOptions extends ServerRouteCreateOptions = never +>(): ServerRouteRepository { + let routes: Record = {}; + + return { + add(route) { + routes = { + ...routes, + [route.endpoint]: route, + }; + + return this as any; + }, + merge(repository) { + routes = { + ...routes, + ...Object.fromEntries(repository.getRoutes().map((route) => [route.endpoint, route])), + }; + + return this as any; + }, + getRoutes: () => Object.values(routes), + }; +} diff --git a/packages/kbn-server-route-repository/src/decode_request_params.test.ts b/packages/kbn-server-route-repository/src/decode_request_params.test.ts new file mode 100644 index 0000000000000..08ef303ad0b3a --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { jsonRt } from '@kbn/io-ts-utils'; +import * as t from 'io-ts'; +import { decodeRequestParams } from './decode_request_params'; + +describe('decodeRequestParams', () => { + it('decodes request params', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + path: { + serviceName: 'opbeans-java', + }, + query: { + start: '', + }, + }); + }); + + it('fails on excess keys', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + extraKey: '', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + + expect(decode).toThrowErrorMatchingInlineSnapshot(` + "Excess keys are not allowed: + path.extraKey" + `); + }); + + it('returns the decoded output', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: { + _inspect: 'true', + }, + body: null, + }, + t.type({ + query: t.type({ + _inspect: jsonRt.pipe(t.boolean), + }), + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + query: { + _inspect: true, + }, + }); + }); + + it('strips empty params', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: {}, + body: {}, + }, + t.type({ + body: t.any, + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({}); + }); +}); diff --git a/packages/kbn-server-route-repository/src/decode_request_params.ts b/packages/kbn-server-route-repository/src/decode_request_params.ts new file mode 100644 index 0000000000000..00492d69b8ac5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { omitBy, isPlainObject, isEmpty } from 'lodash'; +import { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import Boom from '@hapi/boom'; +import { strictKeysRt } from '@kbn/io-ts-utils'; +import { RouteParamsRT } from './typings'; + +interface KibanaRequestParams { + body: unknown; + query: unknown; + params: unknown; +} + +export function decodeRequestParams( + params: KibanaRequestParams, + paramsRt: T +): t.OutputOf { + const paramMap = omitBy( + { + path: params.params, + body: params.body, + query: params.query, + }, + (val) => val === null || val === undefined || (isPlainObject(val) && isEmpty(val)) + ); + + // decode = validate + const result = strictKeysRt(paramsRt).decode(paramMap); + + if (isLeft(result)) { + throw Boom.badRequest(PathReporter.report(result)[0]); + } + + return result.right; +} diff --git a/packages/kbn-server-route-repository/src/format_request.ts b/packages/kbn-server-route-repository/src/format_request.ts new file mode 100644 index 0000000000000..49004a78ce0e0 --- /dev/null +++ b/packages/kbn-server-route-repository/src/format_request.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseEndpoint } from './parse_endpoint'; + +export function formatRequest(endpoint: string, pathParams: Record = {}) { + const { method, pathname: rawPathname } = parseEndpoint(endpoint); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/index.ts b/packages/kbn-server-route-repository/src/index.ts new file mode 100644 index 0000000000000..23621c5b213bc --- /dev/null +++ b/packages/kbn-server-route-repository/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createServerRouteRepository } from './create_server_route_repository'; +export { createServerRouteFactory } from './create_server_route_factory'; +export { formatRequest } from './format_request'; +export { parseEndpoint } from './parse_endpoint'; +export { decodeRequestParams } from './decode_request_params'; +export { routeValidationObject } from './route_validation_object'; +export { + RouteRepositoryClient, + ReturnOf, + EndpointOf, + ClientRequestParamsOf, + DecodedRequestParamsOf, + ServerRouteRepository, + ServerRoute, + RouteParamsRT, +} from './typings'; diff --git a/packages/kbn-server-route-repository/src/parse_endpoint.ts b/packages/kbn-server-route-repository/src/parse_endpoint.ts new file mode 100644 index 0000000000000..fd40489b0f4a5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/parse_endpoint.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint(endpoint: string) { + const parts = endpoint.split(' '); + + const method = parts[0].trim().toLowerCase() as Method; + const pathname = parts[1].trim(); + + if (!['get', 'post', 'put', 'delete'].includes(method)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/route_validation_object.ts b/packages/kbn-server-route-repository/src/route_validation_object.ts new file mode 100644 index 0000000000000..550be8d20d446 --- /dev/null +++ b/packages/kbn-server-route-repository/src/route_validation_object.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema } from '@kbn/config-schema'; + +const anyObject = schema.object({}, { unknowns: 'allow' }); + +export const routeValidationObject = { + // `body` can be null, but `validate` expects non-nullable types + // if any validation is defined. Not having validation currently + // means we don't get the payload. See + // https://github.com/elastic/kibana/issues/50179 + body: schema.nullable(anyObject), + params: anyObject, + query: anyObject, +}; diff --git a/packages/kbn-server-route-repository/src/test_types.ts b/packages/kbn-server-route-repository/src/test_types.ts new file mode 100644 index 0000000000000..c9015e19b82f8 --- /dev/null +++ b/packages/kbn-server-route-repository/src/test_types.ts @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { createServerRouteRepository } from './create_server_route_repository'; +import { decodeRequestParams } from './decode_request_params'; +import { EndpointOf, ReturnOf, RouteRepositoryClient } from './typings'; + +function assertType(value: TShape) { + return value; +} + +// Generic arguments for createServerRouteRepository should be set, +// if not, registering routes should not be allowed +createServerRouteRepository().add({ + // @ts-expect-error + endpoint: 'any_endpoint', + // @ts-expect-error + handler: async ({ params }) => {}, +}); + +// If a params codec is not set, its type should not be available in +// the request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_without_params', + handler: async (resources) => { + // @ts-expect-error Argument of type '{}' is not assignable to parameter of type '{ params: any; }'. + assertType<{ params: any }>(resources); + }, +}); + +// If a params codec is set, its type _should_ be available in the +// request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +// Resources should be passed to the request handler. +createServerRouteRepository<{ context: { getSpaceId: () => string } }, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async ({ context }) => { + const spaceId = context.getSpaceId(); + assertType(spaceId); + }, +}); + +// Create options are available when registering a route. +createServerRouteRepository<{}, { options: { tags: string[] } }>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + options: { + tags: [], + }, + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +const repository = createServerRouteRepository<{}, {}>() + .add({ + endpoint: 'endpoint_without_params', + handler: async () => { + return { + noParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + yesParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_optional_params', + params: t.partial({ + query: t.partial({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + someParamsForMe: true, + }; + }, + }); + +type TestRepository = typeof repository; + +// EndpointOf should return all valid endpoints of a repository + +assertType>>([ + 'endpoint_with_params', + 'endpoint_without_params', + 'endpoint_with_optional_params', +]); + +// @ts-expect-error Type '"this_endpoint_does_not_exist"' is not assignable to type '"endpoint_without_params" | "endpoint_with_params" | "endpoint_with_optional_params"' +assertType>>(['this_endpoint_does_not_exist']); + +// ReturnOf should return the return type of a request handler. + +assertType>({ + noParamsForMe: true, +}); + +const noParamsInvalid: ReturnOf = { + // @ts-expect-error type '{ paramsForMe: boolean; }' is not assignable to type '{ noParamsForMe: boolean; }'. + paramsForMe: true, +}; + +// RouteRepositoryClient + +type TestClient = RouteRepositoryClient; + +const client: TestClient = {} as any; + +// It should respect any additional create options. + +// @ts-expect-error Property 'timeout' is missing +client({ + endpoint: 'endpoint_without_params', +}); + +client({ + endpoint: 'endpoint_without_params', + timeout: 1, +}); + +// It does not allow params for routes without a params codec +client({ + endpoint: 'endpoint_without_params', + // @ts-expect-error Object literal may only specify known properties, and 'params' does not exist in type + params: {}, + timeout: 1, +}); + +// It requires params for routes with a params codec +client({ + endpoint: 'endpoint_with_params', + params: { + // @ts-expect-error property 'serviceName' is missing in type '{}' + path: {}, + }, + timeout: 1, +}); + +// Params are optional if the codec has no required keys +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, +}); + +// If optional, an error will still occur if the params do not match +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, + params: { + // @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type + path: '', + }, +}); + +// The return type is correctly inferred +client({ + endpoint: 'endpoint_with_params', + params: { + path: { + serviceName: '', + }, + }, + timeout: 1, +}).then((res) => { + assertType<{ + noParamsForMe: boolean; + // @ts-expect-error Property 'noParamsForMe' is missing in type + }>(res); + + assertType<{ + yesParamsForMe: boolean; + }>(res); +}); + +// decodeRequestParams should return the type of the codec that is passed +assertType<{ path: { serviceName: string } }>( + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); + +assertType<{ path: { serviceName: boolean } }>( + // @ts-expect-error The types of 'path.serviceName' are incompatible between these types. + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); diff --git a/packages/kbn-server-route-repository/src/typings.ts b/packages/kbn-server-route-repository/src/typings.ts new file mode 100644 index 0000000000000..c27f67c71e88b --- /dev/null +++ b/packages/kbn-server-route-repository/src/typings.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { RequiredKeys } from 'utility-types'; + +type MaybeOptional }> = RequiredKeys< + T['params'] +> extends never + ? { params?: T['params'] } + : { params: T['params'] }; + +type WithoutIncompatibleMethods = Omit & { + encode: t.Encode; + asEncoder: () => t.Encoder; +}; + +export type RouteParamsRT = WithoutIncompatibleMethods< + t.Type<{ + path?: any; + query?: any; + body?: any; + }> +>; + +export interface RouteState { + [endpoint: string]: ServerRoute; +} + +export type ServerRouteHandlerResources = Record; +export type ServerRouteCreateOptions = Record; + +export type ServerRoute< + TEndpoint extends string, + TRouteParamsRT extends RouteParamsRT | undefined, + TRouteHandlerResources extends ServerRouteHandlerResources, + TReturnType, + TRouteCreateOptions extends ServerRouteCreateOptions +> = { + endpoint: TEndpoint; + params?: TRouteParamsRT; + handler: ({}: TRouteHandlerResources & + (TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {})) => Promise; +} & TRouteCreateOptions; + +export interface ServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions = ServerRouteCreateOptions, + TRouteState extends RouteState = RouteState +> { + add< + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined + >( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > + ): ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & + { + [key in TEndpoint]: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + >; + } + >; + merge< + TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions + > + >( + repository: TServerRouteRepository + ): TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + infer TRouteStateToMerge + > + ? ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & TRouteStateToMerge + > + : never; + getRoutes: () => Array< + ServerRoute + >; +} + +type ClientRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.OutputOf; + }> + : {}; + +type DecodedRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.TypeOf; + }> + : {}; + +export type EndpointOf< + TServerRouteRepository extends ServerRouteRepository +> = TServerRouteRepository extends ServerRouteRepository + ? keyof TRouteState + : never; + +export type ReturnOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + any, + any, + infer TReturnType, + ServerRouteCreateOptions + > + ? TReturnType + : never + : never + : never; + +export type DecodedRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {} + : never + : never + : never; + +export type ClientRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? ClientRequestParamsOfType + : {} + : never + : never + : never; + +export type RouteRepositoryClient< + TServerRouteRepository extends ServerRouteRepository, + TAdditionalClientOptions extends Record +> = >( + options: { + endpoint: TEndpoint; + } & ClientRequestParamsOf & + TAdditionalClientOptions +) => Promise>; diff --git a/packages/kbn-server-route-repository/tsconfig.json b/packages/kbn-server-route-repository/tsconfig.json new file mode 100644 index 0000000000000..8f1e72172c675 --- /dev/null +++ b/packages/kbn-server-route-repository/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-server-route-repository/src", + "types": [ + "jest", + "node" + ], + "noUnusedLocals": false + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/index.ts b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts index 5d946b73d9759..f55a9aa80d40d 100644 --- a/packages/kbn-telemetry-tools/src/tools/tasks/index.ts +++ b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts @@ -7,7 +7,9 @@ */ export { ErrorReporter } from './error_reporter'; -export { TaskContext, createTaskContext } from './task_context'; + +export type { TaskContext } from './task_context'; +export { createTaskContext } from './task_context'; export { parseConfigsTask } from './parse_configs_task'; export { extractCollectorsTask } from './extract_collectors_task'; diff --git a/packages/kbn-telemetry-tools/tsconfig.json b/packages/kbn-telemetry-tools/tsconfig.json index 39946fe9907e5..419af1d02f83b 100644 --- a/packages/kbn-telemetry-tools/tsconfig.json +++ b/packages/kbn-telemetry-tools/tsconfig.json @@ -6,7 +6,8 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "sourceRoot": "../../../../packages/kbn-telemetry-tools/src" + "sourceRoot": "../../../../packages/kbn-telemetry-tools/src", + "isolatedModules": true }, "include": [ "src/**/*", diff --git a/packages/kbn-ui-shared-deps/polyfills.js b/packages/kbn-ui-shared-deps/polyfills.js index abbf911cfc8fc..a9ec32023f2bf 100644 --- a/packages/kbn-ui-shared-deps/polyfills.js +++ b/packages/kbn-ui-shared-deps/polyfills.js @@ -8,7 +8,6 @@ require('core-js/stable'); require('regenerator-runtime/runtime'); -require('custom-event-polyfill'); if (typeof window.Event === 'object') { // IE11 doesn't support unknown event types, required by react-use @@ -17,6 +16,4 @@ if (typeof window.Event === 'object') { } require('whatwg-fetch'); -require('abortcontroller-polyfill/dist/polyfill-patch-fetch'); -require('./vendor/childnode_remove_polyfill'); require('symbol-observable'); diff --git a/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js b/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js deleted file mode 100644 index d8818fe809ccb..0000000000000 --- a/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable @kbn/eslint/require-license-header */ - -/* @notice - * This product bundles childnode-remove which is available under a - * "MIT" license. - * - * The MIT License (MIT) - * - * Copyright (c) 2016-present, jszhou - * https://github.com/jserz/js_piece - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -/* eslint-disable */ - -(function (arr) { - arr.forEach(function (item) { - if (item.hasOwnProperty('remove')) { - return; - } - Object.defineProperty(item, 'remove', { - configurable: true, - enumerable: true, - writable: true, - value: function remove() { - if (this.parentNode !== null) - this.parentNode.removeChild(this); - } - }); - }); -})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index b179c998f1126..baf8ed2a61645 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -209,6 +209,7 @@ export class DocLinksService { indexThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-index-threshold.html`, pagerDutyAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/pagerduty-action-type.html`, preconfiguredConnectors: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/pre-configured-connectors.html`, + preconfiguredAlertHistoryConnector: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-action-type.html#preconfigured-connector-alert-history`, serviceNowAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/servicenow-action-type.html#configuring-servicenow`, setupPrerequisites: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`, slackAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/slack-action-type.html#configuring-slack`, diff --git a/src/core/server/config/ensure_valid_configuration.test.ts b/src/core/server/config/ensure_valid_configuration.test.ts index 474e8dd59b4c4..f1006f93dbc2d 100644 --- a/src/core/server/config/ensure_valid_configuration.test.ts +++ b/src/core/server/config/ensure_valid_configuration.test.ts @@ -16,14 +16,40 @@ describe('ensureValidConfiguration', () => { beforeEach(() => { jest.clearAllMocks(); configService = configServiceMock.create(); - configService.getUsedPaths.mockReturnValue(Promise.resolve(['core', 'elastic'])); + + configService.validate.mockResolvedValue(); + configService.getUsedPaths.mockReturnValue(Promise.resolve([])); }); - it('returns normally when there is no unused keys', async () => { - configService.getUnusedPaths.mockResolvedValue([]); + it('returns normally when there is no unused keys and when the config validates', async () => { await expect(ensureValidConfiguration(configService as any)).resolves.toBeUndefined(); }); + it('throws when config validation fails', async () => { + configService.validate.mockImplementation(() => { + throw new Error('some message'); + }); + + await expect(ensureValidConfiguration(configService as any)).rejects.toMatchInlineSnapshot( + `[Error: some message]` + ); + }); + + it('throws a `CriticalError` with the correct processExitCode value when config validation fails', async () => { + expect.assertions(2); + + configService.validate.mockImplementation(() => { + throw new Error('some message'); + }); + + try { + await ensureValidConfiguration(configService as any); + } catch (e) { + expect(e).toBeInstanceOf(CriticalError); + expect(e.processExitCode).toEqual(78); + } + }); + it('throws when there are some unused keys', async () => { configService.getUnusedPaths.mockResolvedValue(['some.key', 'some.other.key']); @@ -44,4 +70,18 @@ describe('ensureValidConfiguration', () => { expect(e.processExitCode).toEqual(64); } }); + + it('does not throw when all unused keys are included in the ignored paths', async () => { + configService.getUnusedPaths.mockResolvedValue(['dev.someDevKey', 'elastic.apm.enabled']); + + await expect(ensureValidConfiguration(configService as any)).resolves.toBeUndefined(); + }); + + it('throws when only some keys are included in the ignored paths', async () => { + configService.getUnusedPaths.mockResolvedValue(['dev.someDevKey', 'some.key']); + + await expect(ensureValidConfiguration(configService as any)).rejects.toMatchInlineSnapshot( + `[Error: Unknown configuration key(s): "some.key". Check for spelling errors and ensure that expected plugins are installed.]` + ); + }); }); diff --git a/src/core/server/config/ensure_valid_configuration.ts b/src/core/server/config/ensure_valid_configuration.ts index a33625cc0841d..c7a4721b7d2ae 100644 --- a/src/core/server/config/ensure_valid_configuration.ts +++ b/src/core/server/config/ensure_valid_configuration.ts @@ -9,22 +9,27 @@ import { ConfigService } from '@kbn/config'; import { CriticalError } from '../errors'; +const ignoredPaths = ['dev.', 'elastic.apm.']; + +const invalidConfigExitCode = 78; +const legacyInvalidConfigExitCode = 64; + export async function ensureValidConfiguration(configService: ConfigService) { - await configService.validate(); + try { + await configService.validate(); + } catch (e) { + throw new CriticalError(e.message, 'InvalidConfig', invalidConfigExitCode, e); + } - const unusedConfigKeys = await configService.getUnusedPaths(); + const unusedPaths = await configService.getUnusedPaths(); + const unusedConfigKeys = unusedPaths.filter((unusedPath) => { + return !ignoredPaths.some((ignoredPath) => unusedPath.startsWith(ignoredPath)); + }); if (unusedConfigKeys.length > 0) { const message = `Unknown configuration key(s): ${unusedConfigKeys .map((key) => `"${key}"`) .join(', ')}. Check for spelling errors and ensure that expected plugins are installed.`; - throw new InvalidConfigurationError(message); - } -} - -class InvalidConfigurationError extends CriticalError { - constructor(message: string) { - super(message, 'InvalidConfig', 64); - Object.setPrototypeOf(this, InvalidConfigurationError.prototype); + throw new CriticalError(message, 'InvalidConfig', legacyInvalidConfigExitCode); } } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index b34d7fec3dcbf..45d11f9013fed 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -36,7 +36,6 @@ import { config as cspConfig } from './csp'; import { config as elasticsearchConfig } from './elasticsearch'; import { config as httpConfig } from './http'; import { config as loggingConfig } from './logging'; -import { config as devConfig } from './dev'; import { config as kibanaConfig } from './kibana_config'; import { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects'; import { config as uiSettingsConfig } from './ui_settings'; @@ -303,7 +302,6 @@ export class Server { loggingConfig, httpConfig, pluginsConfig, - devConfig, kibanaConfig, savedObjectsConfig, savedObjectsMigrationConfig, diff --git a/src/core/server/ui_settings/settings/theme.ts b/src/core/server/ui_settings/settings/theme.ts index cc2919f7555c2..4d2c45a9c84b0 100644 --- a/src/core/server/ui_settings/settings/theme.ts +++ b/src/core/server/ui_settings/settings/theme.ts @@ -48,6 +48,8 @@ export const getThemeSettings = ( ): Record => { const { availableVersions, defaultDarkMode, defaultVersion } = getThemeInfo(options); + const onlyOneThemeAvailable = !options?.isDist && availableVersions.length === 1; + return { 'theme:darkMode': { name: i18n.translate('core.ui_settings.params.darkModeTitle', { @@ -68,10 +70,21 @@ export const getThemeSettings = ( type: 'select', options: availableVersions, description: i18n.translate('core.ui_settings.params.themeVersionText', { - defaultMessage: `Switch between the theme used for the current and next version of Kibana. A page refresh is required for the setting to be applied.`, + defaultMessage: + 'Switch between the theme used for the current and next version of Kibana. A page refresh is required for the setting to be applied. {lessOptions}', + values: { + lessOptions: onlyOneThemeAvailable + ? '

There is only one theme available, set KBN_OPTIMIZER_THEMES=v7light,v7dark,v8light,v8dark to get more options.' + : undefined, + }, }), requiresPageReload: true, schema: schema.oneOf(availableVersions.map((v) => schema.literal(v)) as [Type]), + optionLabels: onlyOneThemeAvailable + ? { + [availableVersions[0]]: `${availableVersions[0]} (only)`, + } + : undefined, }, }; }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 6cc94208fbcce..1ad1559288992 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -159,6 +159,7 @@ kibana_vars=( xpack.actions.allowedHosts xpack.actions.enabled xpack.actions.enabledActionTypes + xpack.actions.preconfiguredAlertHistoryEsIndex xpack.actions.preconfigured xpack.actions.proxyHeaders xpack.actions.proxyRejectUnauthorizedCertificates diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 3d6f08f321977..e7e2ccfd46b9c 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -10,7 +10,7 @@ import { History } from 'history'; import { merge, Subject, Subscription } from 'rxjs'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { debounceTime, tap } from 'rxjs/operators'; +import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators'; import { useKibana } from '../../../kibana_react/public'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardTopNav } from './top_nav/dashboard_top_nav'; @@ -30,7 +30,7 @@ import { useSavedDashboard, } from './hooks'; -import { IndexPattern } from '../services/data'; +import { IndexPattern, waitUntilNextSessionCompletes$ } from '../services/data'; import { EmbeddableRenderer } from '../services/embeddable'; import { DashboardContainerInput } from '.'; import { leaveConfirmStrings } from '../dashboard_strings'; @@ -209,14 +209,26 @@ export function DashboardApp({ ); subscriptions.add( - merge( - data.query.timefilter.timefilter.getAutoRefreshFetch$(), - searchSessionIdQuery$ - ).subscribe(() => { + searchSessionIdQuery$.subscribe(() => { triggerRefresh$.next({ force: true }); }) ); + subscriptions.add( + data.query.timefilter.timefilter + .getAutoRefreshFetch$() + .pipe( + tap(() => { + triggerRefresh$.next({ force: true }); + }), + switchMap((done) => + // best way on a dashboard to estimate that panels are updated is to rely on search session service state + waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done)) + ) + ) + .subscribe() + ); + dashboardStateManager.registerChangeListener(() => { setUnsavedChanges(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter)); // we aren't checking dirty state because there are changes the container needs to know about diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index 60e74a3fa126c..30006e2b497bd 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -5,7 +5,7 @@ title: Data services image: https://source.unsplash.com/400x175/?Search summary: The data plugin contains services for searching, querying and filtering. date: 2020-12-02 -tags: ['kibana','dev', 'contributor', 'api docs'] +tags: ['kibana', 'dev', 'contributor', 'api docs'] --- # data @@ -149,7 +149,6 @@ Index patterns provide Rest-like HTTP CRUD+ API with the following endpoints: - Remove a scripted field — `DELETE /api/index_patterns/index_pattern/{id}/scripted_field/{name}` - Update a scripted field — `POST /api/index_patterns/index_pattern/{id}/scripted_field/{name}` - ### Index Patterns API Index Patterns REST API allows you to create, retrieve and delete index patterns. I also @@ -212,11 +211,10 @@ The endpoint returns the created index pattern object. ```json { - "index_pattern": {} + "index_pattern": {} } ``` - #### Fetch an index pattern by ID Retrieve an index pattern by its ID. @@ -229,23 +227,22 @@ Returns an index pattern object. ```json { - "index_pattern": { - "id": "...", - "version": "...", - "title": "...", - "type": "...", - "intervalName": "...", - "timeFieldName": "...", - "sourceFilters": [], - "fields": {}, - "typeMeta": {}, - "fieldFormats": {}, - "fieldAttrs": {} - } + "index_pattern": { + "id": "...", + "version": "...", + "title": "...", + "type": "...", + "intervalName": "...", + "timeFieldName": "...", + "sourceFilters": [], + "fields": {}, + "typeMeta": {}, + "fieldFormats": {}, + "fieldAttrs": {} + } } ``` - #### Delete an index pattern by ID Delete and index pattern by its ID. @@ -256,21 +253,21 @@ DELETE /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Returns an '200 OK` response with empty body on success. - #### Partially update an index pattern by ID Update part of an index pattern. Only provided fields will be updated on the index pattern, missing fields will stay as they are persisted. These fields can be update partially: - - `title` - - `timeFieldName` - - `intervalName` - - `fields` (optionally refresh fields) - - `sourceFilters` - - `fieldFormatMap` - - `type` - - `typeMeta` + +- `title` +- `timeFieldName` +- `intervalName` +- `fields` (optionally refresh fields) +- `sourceFilters` +- `fieldFormatMap` +- `type` +- `typeMeta` Update a title of an index pattern. @@ -318,18 +315,14 @@ This endpoint returns the updated index pattern object. ```json { - "index_pattern": { - - } + "index_pattern": {} } ``` - ### Fields API Fields API allows to change field metadata, such as `count`, `customLabel`, and `format`. - #### Update fields Update endpoint allows you to update fields presentation metadata, such as `count`, @@ -383,13 +376,10 @@ This endpoint returns the updated index pattern object. ```json { - "index_pattern": { - - } + "index_pattern": {} } ``` - ### Scripted Fields API Scripted Fields API provides CRUD API for scripted fields of an index pattern. @@ -487,7 +477,7 @@ Returns the field object. ```json { - "field": {} + "field": {} } ``` @@ -529,47 +519,86 @@ POST /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/scri } ``` - ## Query The query service is responsible for managing the configuration of a search query (`QueryState`): filters, time range, query string, and settings such as the auto refresh behavior and saved queries. It contains sub-services for each of those configurations: - - `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service. - - `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings. - - `data.query.queryString` - Responsible for the query string and query language settings. - - `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications. - Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes. +- `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service. +- `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings. +- `data.query.queryString` - Responsible for the query string and query language settings. +- `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications. - A simple use case is: +Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes. - ```.ts - function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { - data.query.state$.subscribe(() => { +A simple use case is: - // Constuct the query portion of the search request - const query = data.query.getEsQuery(indexPattern); +```.ts +function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { + data.query.state$.subscribe(() => { + + // Constuct the query portion of the search request + const query = data.query.getEsQuery(indexPattern); + + // Construct a request + const request = { + params: { + index: indexPattern.title, + body: { + aggs: aggConfigs.toDsl(), + query, + }, + }, + }; + + // Search with the `data.query` config + const search$ = data.search.search(request); + + ... + }); +} - // Construct a request - const request = { - params: { - index: indexPattern.title, - body: { - aggs: aggConfigs.toDsl(), - query, - }, - }, - }; +``` - // Search with the `data.query` config - const search$ = data.search.search(request); +### Timefilter - ... - }); - } +`data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings. + +#### Autorefresh - ``` +Timefilter provides an API for setting and getting current auto refresh state: + +```ts +const { pause, value } = data.query.timefilter.timefilter.getRefreshInterval(); + +data.query.timefilter.timefilter.setRefreshInterval({ pause: false, value: 5000 }); // start auto refresh with 5 seconds interval +``` + +Timefilter API also provides an `autoRefreshFetch$` observables that apps should use to get notified +when it is time to refresh data because of auto refresh. +This API expects apps to confirm when they are done with reloading the data. +The confirmation mechanism is needed to prevent excessive queue of fetches. + +``` +import { refetchData } from '../my-app' + +const autoRefreshFetch$ = data.query.timefilter.timefilter.getAutoRefreshFetch$() +autoRefreshFetch$.subscribe((done) => { + try { + await refetchData(); + } finally { + // confirm that data fetching was finished + done(); + } +}) + +function unmount() { + // don't forget to unsubscribe when leaving the app + autoRefreshFetch$.unsubscribe() +} + +``` ## Search diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 805eccd1ee31b..04d2785137719 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -535,6 +535,9 @@ export class IndexPatternsService { }); indexPattern.id = response.id; this.indexPatternCache.set(indexPattern.id, Promise.resolve(indexPattern)); + if (this.savedObjectsCache) { + this.savedObjectsCache.push(response as SavedObject); + } return indexPattern; } diff --git a/src/plugins/data/common/kbn_field_types/types.ts b/src/plugins/data/common/kbn_field_types/types.ts index c46e5c5266f55..e6f815e058ce3 100644 --- a/src/plugins/data/common/kbn_field_types/types.ts +++ b/src/plugins/data/common/kbn_field_types/types.ts @@ -80,4 +80,5 @@ export enum KBN_FIELD_TYPES { OBJECT = 'object', NESTED = 'nested', HISTOGRAM = 'histogram', + MISSING = 'missing', } diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts index 297af560081b1..3ce528e6ed893 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.test.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts @@ -230,7 +230,7 @@ describe('AggConfigs', () => { describe('#toDsl', () => { beforeEach(() => { indexPattern = stubIndexPattern as IndexPattern; - indexPattern.fields.getByName = (name) => (name as unknown) as IndexPatternField; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); }); it('uses the sorted aggs', () => { diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 4e278d5872a3e..56e720d237c45 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -16,16 +16,33 @@ import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; import { BUCKET_TYPES } from './bucket_agg_types'; import { IBucketAggConfig } from './bucket_agg_type'; import { mockAggTypesRegistry } from '../test_helpers'; +import type { IndexPatternField } from '../../../index_patterns'; +import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern'; const indexPattern = { id: '1234', title: 'logstash-*', fields: [ { - name: 'field', + name: 'machine.os.raw', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'geo.src', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, }, ], -} as any; +} as IndexPattern; + +indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); const singleTerm = { aggs: [ diff --git a/src/plugins/data/common/search/aggs/buckets/terms.test.ts b/src/plugins/data/common/search/aggs/buckets/terms.test.ts index bb34d7ede453c..09dfbb28a4e53 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.test.ts @@ -10,6 +10,8 @@ import { AggConfigs } from '../agg_configs'; import { METRIC_TYPES } from '../metrics'; import { mockAggTypesRegistry } from '../test_helpers'; import { BUCKET_TYPES } from './bucket_agg_types'; +import type { IndexPatternField } from '../../../index_patterns'; +import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern'; describe('Terms Agg', () => { describe('order agg editor UI', () => { @@ -17,16 +19,44 @@ describe('Terms Agg', () => { const indexPattern = { id: '1234', title: 'logstash-*', - fields: { - getByName: () => field, - filter: () => [field], - }, - } as any; + fields: [ + { + name: 'field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'string_field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'empty_number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + } as IndexPattern; - const field = { - name: 'field', - indexPattern, - }; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + indexPattern.fields.filter = () => indexPattern.fields; return new AggConfigs( indexPattern, @@ -207,16 +237,28 @@ describe('Terms Agg', () => { const indexPattern = { id: '1234', title: 'logstash-*', - fields: { - getByName: () => field, - filter: () => [field], - }, - } as any; + fields: [ + { + name: 'string_field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + } as IndexPattern; - const field = { - name: 'field', - indexPattern, - }; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + indexPattern.fields.filter = () => indexPattern.fields; const aggConfigs = new AggConfigs( indexPattern, diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 7d37dc83405b8..77c9c6e391c0a 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -8,7 +8,6 @@ import { noop } from 'lodash'; import { i18n } from '@kbn/i18n'; -import type { RequestAdapter } from 'src/plugins/inspector/common'; import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; @@ -21,7 +20,6 @@ import { aggTermsFnName } from './terms_fn'; import { AggConfigSerialized, BaseAggParams } from '../types'; import { KBN_FIELD_TYPES } from '../../../../common'; -import { getRequestInspectorStats, getResponseInspectorStats } from '../../expressions'; import { buildOtherBucketAgg, @@ -103,36 +101,28 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - let request: ReturnType | undefined; - if (inspectorRequestAdapter) { - request = inspectorRequestAdapter.start( - i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { - defaultMessage: 'Other bucket', + const requestResponder = inspectorRequestAdapter?.start( + i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { + defaultMessage: 'Other bucket', + }), + { + description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { + defaultMessage: + 'This request counts the number of documents that fall ' + + 'outside the criterion of the data buckets.', }), - { - description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { - defaultMessage: - 'This request counts the number of documents that fall ' + - 'outside the criterion of the data buckets.', - }), - searchSessionId, - } - ); - nestedSearchSource.getSearchRequestBody().then((body) => { - request!.json(body); - }); - request.stats(getRequestInspectorStats(nestedSearchSource)); - } + searchSessionId, + } + ); + + const response = await nestedSearchSource + .fetch$({ + abortSignal, + sessionId: searchSessionId, + requestResponder, + }) + .toPromise(); - const response = await nestedSearchSource.fetch({ - abortSignal, - sessionId: searchSessionId, - }); - if (request) { - request - .stats(getResponseInspectorStats(response, nestedSearchSource)) - .ok({ json: response }); - } resp = mergeOtherBucketAggResponse(aggConfigs, resp, response, aggConfig, filterAgg()); } if (aggConfig.params.missingBucket) { diff --git a/src/plugins/data/common/search/aggs/param_types/field.ts b/src/plugins/data/common/search/aggs/param_types/field.ts index 2d3ff8f5fdba8..62dac9831211a 100644 --- a/src/plugins/data/common/search/aggs/param_types/field.ts +++ b/src/plugins/data/common/search/aggs/param_types/field.ts @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import { IAggConfig } from '../agg_config'; -import { SavedObjectNotFound } from '../../../../../../plugins/kibana_utils/common'; +import { + SavedFieldNotFound, + SavedFieldTypeInvalidForAgg, +} from '../../../../../../plugins/kibana_utils/common'; import { BaseParamType } from './base'; import { propFilter } from '../utils'; import { KBN_FIELD_TYPES } from '../../../kbn_field_types/types'; @@ -47,13 +50,49 @@ export class FieldParamType extends BaseParamType { ); } - if (field.scripted) { + if (field.type === KBN_FIELD_TYPES.MISSING) { + throw new SavedFieldNotFound( + i18n.translate( + 'data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage', + { + defaultMessage: + 'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.', + values: { + fieldParameter: field.name, + }, + } + ) + ); + } + + const validField = this.getAvailableFields(aggConfig).find( + (f: any) => f.name === field.name + ); + + if (!validField) { + throw new SavedFieldTypeInvalidForAgg( + i18n.translate( + 'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage', + { + defaultMessage: + 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.', + values: { + fieldParameter: field.name, + aggType: aggConfig?.type?.title, + indexPatternTitle: aggConfig.getIndexPattern().title, + }, + } + ) + ); + } + + if (validField.scripted) { output.params.script = { - source: field.script, - lang: field.lang, + source: validField.script, + lang: validField.lang, }; } else { - output.params.field = field.name; + output.params.field = validField.name; } }; } @@ -69,28 +108,15 @@ export class FieldParamType extends BaseParamType { const field = aggConfig.getIndexPattern().fields.getByName(fieldName); if (!field) { - throw new SavedObjectNotFound('index-pattern-field', fieldName); - } - - const validField = this.getAvailableFields(aggConfig).find((f: any) => f.name === fieldName); - if (!validField) { - throw new Error( - i18n.translate( - 'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage', - { - defaultMessage: - 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.', - values: { - fieldParameter: fieldName, - aggType: aggConfig?.type?.title, - indexPatternTitle: aggConfig.getIndexPattern().title, - }, - } - ) - ); + return new IndexPatternField({ + type: KBN_FIELD_TYPES.MISSING, + name: fieldName, + searchable: false, + aggregatable: false, + }); } - return validField; + return field; }; } diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index 7580032b0dd85..c2566535916a8 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -133,7 +133,7 @@ describe('esaggs expression function - public', () => { test('calls searchSource.fetch', async () => { await handleRequest(mockParams); const searchSource = await mockParams.searchSourceService.create(); - expect(searchSource.fetch).toHaveBeenCalledWith({ + expect(searchSource.fetch$).toHaveBeenCalledWith({ abortSignal: mockParams.abortSignal, sessionId: mockParams.searchSessionId, }); diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 72d9cc4095570..5620698a47538 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -22,7 +22,6 @@ import { import { IAggConfigs } from '../../aggs'; import { ISearchStartSearchSource } from '../../search_source'; import { tabifyAggResponse } from '../../tabify'; -import { getRequestInspectorStats, getResponseInspectorStats } from '../utils'; /** @internal */ export interface RequestHandlerParams { @@ -41,6 +40,21 @@ export interface RequestHandlerParams { getNow?: () => Date; } +function getRequestMainResponder(inspectorAdapters: Adapters, searchSessionId?: string) { + return inspectorAdapters.requests?.start( + i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { + defaultMessage: 'Data', + }), + { + description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the visualization.', + }), + searchSessionId, + } + ); +} + export const handleRequest = async ({ abortSignal, aggs, @@ -113,52 +127,19 @@ export const handleRequest = async ({ requestSearchSource.setField('filter', filters); requestSearchSource.setField('query', query); - let request; - if (inspectorAdapters.requests) { - inspectorAdapters.requests.reset(); - request = inspectorAdapters.requests.start( - i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { - defaultMessage: 'Data', - }), - { - description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { - defaultMessage: - 'This request queries Elasticsearch to fetch the data for the visualization.', - }), - searchSessionId, - } - ); - request.stats(getRequestInspectorStats(requestSearchSource)); - } - - try { - const response = await requestSearchSource.fetch({ - abortSignal, - sessionId: searchSessionId, - }); - - if (request) { - request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response }); - } + inspectorAdapters.requests?.reset(); + const requestResponder = getRequestMainResponder(inspectorAdapters, searchSessionId); - (searchSource as any).rawResponse = response; - } catch (e) { - // Log any error during request to the inspector - if (request) { - request.error({ json: e }); - } - throw e; - } finally { - // Add the request body no matter if things went fine or not - if (request) { - request.json(await requestSearchSource.getSearchRequestBody()); - } - } + const response$ = await requestSearchSource.fetch$({ + abortSignal, + sessionId: searchSessionId, + requestResponder, + }); // Note that rawResponse is not deeply cloned here, so downstream applications using courier // must take care not to mutate it, or it could have unintended side effects, e.g. displaying // response data incorrectly in the inspector. - let response = (searchSource as any).rawResponse; + let response = await response$.toPromise(); for (const agg of aggs.aggs) { if (agg.enabled && typeof agg.type.postFlightRequest === 'function') { response = await agg.type.postFlightRequest( diff --git a/src/plugins/data/common/search/expressions/utils/index.ts b/src/plugins/data/common/search/expressions/utils/index.ts index 2fa54d47445b3..a6ea8da6ac6e9 100644 --- a/src/plugins/data/common/search/expressions/utils/index.ts +++ b/src/plugins/data/common/search/expressions/utils/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export * from './courier_inspector_stats'; export * from './function_wrapper'; diff --git a/src/plugins/data/common/search/search_source/index.ts b/src/plugins/data/common/search/search_source/index.ts index 1cb04075dad7a..757e0de6ecb49 100644 --- a/src/plugins/data/common/search/search_source/index.ts +++ b/src/plugins/data/common/search/search_source/index.ts @@ -10,6 +10,7 @@ export { createSearchSource } from './create_search_source'; export { injectReferences } from './inject_references'; export { extractReferences } from './extract_references'; export { parseSearchSourceJSON } from './parse_json'; +export { getResponseInspectorStats } from './inspect'; export * from './fetch'; export * from './legacy'; export * from './search_source'; diff --git a/src/core/server/dev/index.ts b/src/plugins/data/common/search/search_source/inspect/index.ts similarity index 90% rename from src/core/server/dev/index.ts rename to src/plugins/data/common/search/search_source/inspect/index.ts index 70257d2a5e6c5..d5947f8a18cc9 100644 --- a/src/core/server/dev/index.ts +++ b/src/plugins/data/common/search/search_source/inspect/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { config } from './dev_config'; +export * from './inspector_stats'; diff --git a/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts similarity index 97% rename from src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts rename to src/plugins/data/common/search/search_source/inspect/inspector_stats.ts index 99acbce8935c4..24507a7e13058 100644 --- a/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts +++ b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts @@ -15,8 +15,8 @@ import { i18n } from '@kbn/i18n'; import type { estypes } from '@elastic/elasticsearch'; -import { ISearchSource } from 'src/plugins/data/public'; -import { RequestStatistics } from 'src/plugins/inspector/common'; +import type { ISearchSource } from 'src/plugins/data/public'; +import type { RequestStatistics } from 'src/plugins/inspector/common'; /** @public */ export function getRequestInspectorStats(searchSource: ISearchSource) { diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index fd97a3d3381a9..3726e5d0c33e8 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -125,7 +125,7 @@ describe('SearchSource', () => { }), } as unknown) as IndexPattern); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.stored_fields).toEqual(['hello']); expect(request.script_fields).toEqual({ world: {} }); expect(request.fields).toEqual(['@timestamp']); @@ -144,7 +144,7 @@ describe('SearchSource', () => { searchSource.setField('fields', ['@timestamp']); searchSource.setField('fieldsFromSource', ['foo']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).not.toHaveProperty('docvalue_fields'); }); @@ -160,7 +160,7 @@ describe('SearchSource', () => { // @ts-expect-error TS won't like using this field name, but technically it's possible. searchSource.setField('docvalue_fields', ['world']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('docvalue_fields'); expect(request.docvalue_fields).toEqual(['world']); }); @@ -179,7 +179,7 @@ describe('SearchSource', () => { searchSource.setField('fields', ['c']); searchSource.setField('fieldsFromSource', ['a', 'b', 'd']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('docvalue_fields'); expect(request._source.includes).toEqual(['c', 'a', 'b', 'd']); expect(request.docvalue_fields).toEqual([{ field: 'b', format: 'date_time' }]); @@ -202,7 +202,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', [{ field: 'hello', format: 'strict_date_time' }]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('fields'); expect(request.fields).toEqual([{ field: 'hello', format: 'strict_date_time' }]); }); @@ -218,7 +218,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('fields'); expect(request.fields).toEqual([{ field: 'hello', format: 'date_time' }]); }); @@ -239,7 +239,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', [{ field: 'hello', a: 'a', c: 'c' }]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('fields'); expect(request.fields).toEqual([ { field: 'hello', format: 'date_time', a: 'a', b: 'test', c: 'c' }, @@ -258,7 +258,7 @@ describe('SearchSource', () => { // @ts-expect-error TS won't like using this field name, but technically it's possible. searchSource.setField('script_fields', { world: {} }); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request).toHaveProperty('script_fields'); expect(request.script_fields).toEqual({ hello: {}, @@ -277,7 +277,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello', 'a', { field: 'c' }]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['a', 'c']); }); @@ -293,7 +293,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello', 'a', { foo: 'c' }]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['a']); }); @@ -309,23 +309,23 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fieldsFromSource', ['hello', 'a']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['a']); }); test('defaults to * for stored fields when no fields are provided', async () => { - const requestA = await searchSource.getSearchRequestBody(); + const requestA = searchSource.getSearchRequestBody(); expect(requestA.stored_fields).toEqual(['*']); searchSource.setField('fields', ['*']); - const requestB = await searchSource.getSearchRequestBody(); + const requestB = searchSource.getSearchRequestBody(); expect(requestB.stored_fields).toEqual(['*']); }); test('defaults to * for stored fields when no fields are provided with fieldsFromSource', async () => { searchSource.setField('fieldsFromSource', ['*']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.stored_fields).toEqual(['*']); }); }); @@ -343,7 +343,7 @@ describe('SearchSource', () => { // @ts-expect-error Typings for excludes filters need to be fixed. searchSource.setField('source', { excludes: ['exclude-*'] }); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual(['@timestamp']); }); @@ -357,7 +357,7 @@ describe('SearchSource', () => { }), } as unknown) as IndexPattern); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual(['@timestamp']); }); @@ -372,7 +372,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {} }); }); @@ -387,7 +387,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello', 'foo']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual(['hello']); }); @@ -402,7 +402,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['*']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual([{ field: 'field1' }, { field: 'field2' }]); }); @@ -417,7 +417,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', [{ field: '*', include_unmapped: 'true' }]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual([{ field: 'field1' }, { field: 'field2' }]); }); @@ -432,7 +432,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['timestamp', '*']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {}, world: {} }); }); }); @@ -455,7 +455,7 @@ describe('SearchSource', () => { 'bar-b', ]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toEqual({ includes: ['@timestamp', 'bar-b'], }); @@ -473,7 +473,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['hello', '@timestamp', 'foo-a', 'bar']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual(['hello', '@timestamp', 'bar', 'date']); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['@timestamp', 'bar']); @@ -498,7 +498,7 @@ describe('SearchSource', () => { 'runtime_field', ]); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toEqual({ includes: ['@timestamp', 'bar'], }); @@ -520,7 +520,7 @@ describe('SearchSource', () => { searchSource.setField('fields', ['hello', '@timestamp', 'foo-a', 'bar']); searchSource.setField('fieldsFromSource', ['foo-b', 'date', 'baz']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toEqual({ includes: ['@timestamp', 'bar', 'date', 'baz'], }); @@ -546,7 +546,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['*']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual([ '*', { field: '@timestamp', format: 'strict_date_optional_time_nanos' }, @@ -574,7 +574,7 @@ describe('SearchSource', () => { } as unknown) as IndexPattern); searchSource.setField('fields', ['*']); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.fields).toEqual([ { field: 'foo-bar' }, { field: 'field1' }, @@ -592,14 +592,14 @@ describe('SearchSource', () => { expect(searchSource.getField('source')).toBe(undefined); searchSource.setField('index', indexPattern); expect(searchSource.getField('index')).toBe(indexPattern); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toBe(mockSource); }); test('removes created searchSource filter on removal', async () => { searchSource.setField('index', indexPattern); searchSource.setField('index', undefined); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toBe(undefined); }); }); @@ -609,7 +609,7 @@ describe('SearchSource', () => { searchSource.setField('index', indexPattern); searchSource.setField('index', indexPattern2); expect(searchSource.getField('index')).toBe(indexPattern2); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toBe(mockSource2); }); @@ -617,7 +617,7 @@ describe('SearchSource', () => { searchSource.setField('index', indexPattern); searchSource.setField('index', indexPattern2); searchSource.setField('index', undefined); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request._source).toBe(undefined); }); }); @@ -808,7 +808,7 @@ describe('SearchSource', () => { docvalueFields: [], }), } as unknown) as IndexPattern); - const request = await searchSource.getSearchRequestBody(); + const request = searchSource.getSearchRequestBody(); expect(request.stored_fields).toEqual(['geometry', 'prop1']); expect(request.docvalue_fields).toEqual(['prop1']); expect(request._source).toEqual(['geometry']); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index f11e7f06b6ab9..e1e7a8292d677 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,7 +60,7 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, isFunction, isEqual, uniqWith, isObject } from 'lodash'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators'; import { defer, from } from 'rxjs'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; @@ -73,6 +73,7 @@ import type { SearchSourceFields, } from './types'; import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch'; +import { getRequestInspectorStats, getResponseInspectorStats } from './inspect'; import { getEsQueryConfig, buildEsQuery, Filter, UI_SETTINGS } from '../../../common'; import { getHighlightRequest } from '../../../common/field_formats'; @@ -256,6 +257,9 @@ export class SearchSource { fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; return defer(() => this.requestIsStarting(options)).pipe( + tap(() => { + options.requestResponder?.stats(getRequestInspectorStats(this)); + }), switchMap(() => { const searchRequest = this.flatten(); this.history = [searchRequest]; @@ -271,7 +275,17 @@ export class SearchSource { // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved if ((response as any).error) { throw new RequestFailure(null, response); + } else { + options.requestResponder?.stats(getResponseInspectorStats(response, this)); + options.requestResponder?.ok({ json: response }); } + }), + catchError((e) => { + options.requestResponder?.error({ json: e }); + throw e; + }), + finalize(() => { + options.requestResponder?.json(this.getSearchRequestBody()); }) ); } @@ -298,9 +312,8 @@ export class SearchSource { /** * Returns body contents of the search request, often referred as query DSL. */ - async getSearchRequestBody() { - const searchRequest = await this.flatten(); - return searchRequest.body; + getSearchRequestBody() { + return this.flatten().body; } /** diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index d77a2ea62bb9a..37de8dc49d3c6 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -9,6 +9,7 @@ import { Observable } from 'rxjs'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; import { IndexPattern } from '..'; +import type { RequestResponder } from '../../../inspector/common'; export type ISearchGeneric = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, @@ -118,6 +119,8 @@ export interface ISearchOptions { */ indexPattern?: IndexPattern; + + requestResponder?: RequestResponder; } /** diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index c47cd6cd9740d..e86b64d135d59 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -314,8 +314,6 @@ import { boundsDescendingRaw, getNumberHistogramIntervalByDatatableColumn, getDateHistogramMetaDataByDatatableColumn, - // expressions utils - getRequestInspectorStats, getResponseInspectorStats, // tabify tabifyAggResponse, @@ -388,6 +386,8 @@ export { PainlessError, noSearchSessionStorageCapabilityMessage, SEARCH_SESSIONS_MANAGEMENT_ID, + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, } from './search'; export type { @@ -426,7 +426,6 @@ export const search = { getNumberHistogramIntervalByDatatableColumn, getDateHistogramMetaDataByDatatableColumn, }, - getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse, tabifyGetColumns, @@ -467,6 +466,7 @@ export { TimeHistoryContract, QueryStateChange, QueryStart, + AutoRefreshDoneFn, } from './query'; export { AggsStart } from './search/aggs'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index ec24a9296674d..c4e54c64af132 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -504,6 +504,11 @@ export interface ApplyGlobalFilterActionContext { // @public (undocumented) export type AutocompleteStart = ReturnType; +// Warning: (ae-missing-release-tag) "AutoRefreshDoneFn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AutoRefreshDoneFn = () => void; + // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateNanosFormat" 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) @@ -1555,7 +1560,6 @@ export type IndexPatternSelectProps = Required, 'isLo indexPatternId: string; fieldTypes?: string[]; onNoIndexPatterns?: () => void; - maxIndexPatterns?: number; }; // Warning: (ae-missing-release-tag) "IndexPatternSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1672,6 +1676,10 @@ export interface ISearchOptions { isRestore?: boolean; isStored?: boolean; legacyHitsTotal?: boolean; + // Warning: (ae-forgotten-export) The symbol "RequestResponder" needs to be exported by the entry point index.d.ts + // + // (undocumented) + requestResponder?: RequestResponder; sessionId?: string; strategy?: string; } @@ -1781,6 +1789,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) IP_RANGE = "ip_range", // (undocumented) + MISSING = "missing", + // (undocumented) MURMUR3 = "murmur3", // (undocumented) NESTED = "nested", @@ -2292,7 +2302,6 @@ export const search: { timeRange: import("../common").TimeRange | undefined; } | undefined; }; - getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; tabifyAggResponse: typeof tabifyAggResponse; tabifyGetColumns: typeof tabifyGetColumns; @@ -2428,7 +2437,7 @@ export class SearchSource { getId(): string; getOwnField(field: K): SearchSourceFields[K]; getParent(): SearchSource | undefined; - getSearchRequestBody(): Promise; + getSearchRequestBody(): any; getSerializedFields(recurse?: boolean): SearchSourceFields; // Warning: (ae-incompatible-release-tags) The symbol "history" is marked as @public, but its signature references "SearchRequest" which is marked as @internal // @@ -2647,6 +2656,18 @@ export const UI_SETTINGS: { readonly AUTOCOMPLETE_USE_TIMERANGE: "autocomplete:useTimeRange"; }; +// Warning: (ae-missing-release-tag) "waitUntilNextSessionCompletes$" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable; + +// Warning: (ae-missing-release-tag) "WaitUntilNextSessionCompletesOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface WaitUntilNextSessionCompletesOptions { + waitForIdle?: number; +} + // Warnings were encountered during analysis: // @@ -2694,7 +2715,6 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts index 83e897824d86c..3dfd4e0fe514f 100644 --- a/src/plugins/data/public/query/timefilter/index.ts +++ b/src/plugins/data/public/query/timefilter/index.ts @@ -9,7 +9,7 @@ export { TimefilterService, TimefilterSetup } from './timefilter_service'; export * from './types'; -export { Timefilter, TimefilterContract } from './timefilter'; +export { Timefilter, TimefilterContract, AutoRefreshDoneFn } from './timefilter'; export { TimeHistory, TimeHistoryContract } from './time_history'; export { changeTimeFilter, convertRangeFilterToTimeRangeString } from './lib/change_time_filter'; export { extractTimeFilter, extractTimeRange } from './lib/extract_time_filter'; diff --git a/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts new file mode 100644 index 0000000000000..3c8b316c3b878 --- /dev/null +++ b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createAutoRefreshLoop, AutoRefreshDoneFn } from './auto_refresh_loop'; + +jest.useFakeTimers(); + +test('triggers refresh with interval', () => { + const { loop$, start, stop } = createAutoRefreshLoop(); + + const fn = jest.fn((done) => done()); + loop$.subscribe(fn); + + jest.advanceTimersByTime(5000); + expect(fn).not.toBeCalled(); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(2); + + stop(); + + jest.advanceTimersByTime(5000); + expect(fn).toHaveBeenCalledTimes(2); +}); + +test('waits for done() to be called', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done!: AutoRefreshDoneFn; + const fn = jest.fn((_done) => { + done = _done; + }); + loop$.subscribe(fn); + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + expect(done).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + + done(); + + jest.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn).toHaveBeenCalledTimes(2); +}); + +test('waits for done() from multiple subscribers to be called', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + let done2!: AutoRefreshDoneFn; + const fn2 = jest.fn((_done) => { + done2 = _done; + }); + loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + done2(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); + +test('unsubscribe() resets the state', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + const fn2 = jest.fn(); + const sub2 = loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + sub2.unsubscribe(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); + +test('calling done() twice is ignored', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + const fn2 = jest.fn(); + loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); +}); + +test('calling older done() is ignored', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + // @ts-ignore + if (done1) return; + done1 = _done; + }); + loop$.subscribe(fn1); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); diff --git a/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts new file mode 100644 index 0000000000000..1e213b36e1d8b --- /dev/null +++ b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { defer, Subject } from 'rxjs'; +import { finalize, map } from 'rxjs/operators'; +import { once } from 'lodash'; + +export type AutoRefreshDoneFn = () => void; + +/** + * Creates a loop for timepicker's auto refresh + * It has a "confirmation" mechanism: + * When auto refresh loop emits, it won't continue automatically, + * until each subscriber calls received `done` function. + * + * @internal + */ +export const createAutoRefreshLoop = () => { + let subscribersCount = 0; + const tick = new Subject(); + + let _timeoutHandle: number; + let _timeout: number = 0; + + function start() { + stop(); + if (_timeout === 0) return; + const timeoutHandle = window.setTimeout(() => { + let pendingDoneCount = subscribersCount; + const done = () => { + if (timeoutHandle !== _timeoutHandle) return; + + pendingDoneCount--; + if (pendingDoneCount === 0) { + start(); + } + }; + tick.next(done); + }, _timeout); + + _timeoutHandle = timeoutHandle; + } + + function stop() { + window.clearTimeout(_timeoutHandle); + _timeoutHandle = -1; + } + + return { + stop: () => { + _timeout = 0; + stop(); + }, + start: (timeout: number) => { + _timeout = timeout; + if (subscribersCount > 0) { + start(); + } + }, + loop$: defer(() => { + subscribersCount++; + start(); // restart the loop on a new subscriber + return tick.pipe(map((doneCb) => once(doneCb))); // each subscriber allowed to call done only once + }).pipe( + finalize(() => { + subscribersCount--; + if (subscribersCount === 0) { + stop(); + } else { + start(); // restart the loop to potentially unblock the interval + } + }) + ), + }; +}; diff --git a/src/plugins/data/public/query/timefilter/timefilter.test.ts b/src/plugins/data/public/query/timefilter/timefilter.test.ts index 8e1e76ed19e6d..92ee6b0c30428 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.test.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.test.ts @@ -10,7 +10,7 @@ jest.useFakeTimers(); import sinon from 'sinon'; import moment from 'moment'; -import { Timefilter } from './timefilter'; +import { AutoRefreshDoneFn, Timefilter } from './timefilter'; import { Subscription } from 'rxjs'; import { TimeRange, RefreshInterval } from '../../../common'; import { createNowProviderMock } from '../../now_provider/mocks'; @@ -121,7 +121,7 @@ describe('setRefreshInterval', () => { beforeEach(() => { update = sinon.spy(); fetch = sinon.spy(); - autoRefreshFetch = sinon.spy(); + autoRefreshFetch = sinon.spy((done) => done()); timefilter.setRefreshInterval({ pause: false, value: 0, @@ -344,3 +344,44 @@ describe('calculateBounds', () => { expect(() => timefilter.calculateBounds(timeRange)).toThrowError(); }); }); + +describe('getAutoRefreshFetch$', () => { + test('next auto refresh loop starts after "done" called', () => { + const autoRefreshFetch = jest.fn(); + let doneCb: AutoRefreshDoneFn | undefined; + timefilter.getAutoRefreshFetch$().subscribe((done) => { + autoRefreshFetch(); + doneCb = done; + }); + timefilter.setRefreshInterval({ pause: false, value: 1000 }); + + expect(autoRefreshFetch).toBeCalledTimes(0); + jest.advanceTimersByTime(5000); + expect(autoRefreshFetch).toBeCalledTimes(1); + + if (doneCb) doneCb(); + + jest.advanceTimersByTime(1005); + expect(autoRefreshFetch).toBeCalledTimes(2); + }); + + test('new getAutoRefreshFetch$ subscription restarts refresh loop', () => { + const autoRefreshFetch = jest.fn(); + const fetch$ = timefilter.getAutoRefreshFetch$(); + const sub1 = fetch$.subscribe((done) => { + autoRefreshFetch(); + // this done will be never called, but loop will be reset by another subscription + }); + timefilter.setRefreshInterval({ pause: false, value: 1000 }); + + expect(autoRefreshFetch).toBeCalledTimes(0); + jest.advanceTimersByTime(5000); + expect(autoRefreshFetch).toBeCalledTimes(1); + + fetch$.subscribe(autoRefreshFetch); + expect(autoRefreshFetch).toBeCalledTimes(1); + sub1.unsubscribe(); + jest.advanceTimersByTime(1005); + expect(autoRefreshFetch).toBeCalledTimes(2); + }); +}); diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts index 436b18f70a2f8..9894010601d2b 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.ts @@ -22,6 +22,9 @@ import { TimeRange, } from '../../../common'; import { TimeHistoryContract } from './time_history'; +import { createAutoRefreshLoop, AutoRefreshDoneFn } from './lib/auto_refresh_loop'; + +export { AutoRefreshDoneFn }; // TODO: remove! @@ -32,8 +35,6 @@ export class Timefilter { private timeUpdate$ = new Subject(); // Fired when a user changes the the autorefresh settings private refreshIntervalUpdate$ = new Subject(); - // Used when an auto refresh is triggered - private autoRefreshFetch$ = new Subject(); private fetch$ = new Subject(); private _time: TimeRange; @@ -45,11 +46,12 @@ export class Timefilter { private _isTimeRangeSelectorEnabled: boolean = false; private _isAutoRefreshSelectorEnabled: boolean = false; - private _autoRefreshIntervalId: number = 0; - private readonly timeDefaults: TimeRange; private readonly refreshIntervalDefaults: RefreshInterval; + // Used when an auto refresh is triggered + private readonly autoRefreshLoop = createAutoRefreshLoop(); + constructor( config: TimefilterConfig, timeHistory: TimeHistoryContract, @@ -86,9 +88,13 @@ export class Timefilter { return this.refreshIntervalUpdate$.asObservable(); }; - public getAutoRefreshFetch$ = () => { - return this.autoRefreshFetch$.asObservable(); - }; + /** + * Get an observable that emits when it is time to refetch data due to refresh interval + * Each subscription to this observable resets internal interval + * Emitted value is a callback {@link AutoRefreshDoneFn} that must be called to restart refresh interval loop + * Apps should use this callback to start next auto refresh loop when view finished updating + */ + public getAutoRefreshFetch$ = () => this.autoRefreshLoop.loop$; public getFetch$ = () => { return this.fetch$.asObservable(); @@ -166,13 +172,9 @@ export class Timefilter { } } - // Clear the previous auto refresh interval and start a new one (if not paused) - clearInterval(this._autoRefreshIntervalId); - if (!newRefreshInterval.pause) { - this._autoRefreshIntervalId = window.setInterval( - () => this.autoRefreshFetch$.next(), - newRefreshInterval.value - ); + this.autoRefreshLoop.stop(); + if (!newRefreshInterval.pause && newRefreshInterval.value !== 0) { + this.autoRefreshLoop.start(newRefreshInterval.value); } }; diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts index 0f2b01f618186..c22f62f45a709 100644 --- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts +++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts @@ -20,7 +20,7 @@ const createSetupContractMock = () => { getEnabledUpdated$: jest.fn(), getTimeUpdate$: jest.fn(), getRefreshIntervalUpdate$: jest.fn(), - getAutoRefreshFetch$: jest.fn(() => new Observable()), + getAutoRefreshFetch$: jest.fn(() => new Observable<() => void>()), getFetch$: jest.fn(), getTime: jest.fn(), setTime: jest.fn(), diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index fded4c46992c0..92a5c36202e6f 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -45,6 +45,8 @@ export { ISessionsClient, noSearchSessionStorageCapabilityMessage, SEARCH_SESSIONS_MANAGEMENT_ID, + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, } from './session'; export { getEsPreference } from './es_search'; diff --git a/src/plugins/data/public/search/session/index.ts b/src/plugins/data/public/search/session/index.ts index 15410400a33e6..ce578378a2fe8 100644 --- a/src/plugins/data/public/search/session/index.ts +++ b/src/plugins/data/public/search/session/index.ts @@ -11,3 +11,7 @@ export { SearchSessionState } from './search_session_state'; export { SessionsClient, ISessionsClient } from './sessions_client'; export { noSearchSessionStorageCapabilityMessage } from './i18n'; export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; +export { + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, +} from './session_helpers'; diff --git a/src/plugins/data/public/search/session/session_helpers.test.ts b/src/plugins/data/public/search/session/session_helpers.test.ts new file mode 100644 index 0000000000000..5b64e7b554d18 --- /dev/null +++ b/src/plugins/data/public/search/session/session_helpers.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { waitUntilNextSessionCompletes$ } from './session_helpers'; +import { ISessionService, SessionService } from './session_service'; +import { BehaviorSubject } from 'rxjs'; +import { SearchSessionState } from './search_session_state'; +import { NowProviderInternalContract } from '../../now_provider'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createNowProviderMock } from '../../now_provider/mocks'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; +import { getSessionsClientMock } from './mocks'; + +let sessionService: ISessionService; +let state$: BehaviorSubject; +let nowProvider: jest.Mocked; +let currentAppId$: BehaviorSubject; + +beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext(); + const startService = coreMock.createSetup().getStartServices; + nowProvider = createNowProviderMock(); + currentAppId$ = new BehaviorSubject('app'); + sessionService = new SessionService( + initializerContext, + () => + startService().then(([coreStart, ...rest]) => [ + { + ...coreStart, + application: { + ...coreStart.application, + currentAppId$, + capabilities: { + ...coreStart.application.capabilities, + management: { + kibana: { + [SEARCH_SESSIONS_MANAGEMENT_ID]: true, + }, + }, + }, + }, + }, + ...rest, + ]), + getSessionsClientMock(), + nowProvider, + { freezeState: false } // needed to use mocks inside state container + ); + state$ = new BehaviorSubject(SearchSessionState.None); + sessionService.state$.subscribe(state$); +}); + +describe('waitUntilNextSessionCompletes$', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + test('emits when next session starts', () => { + sessionService.start(); + let untrackSearch = sessionService.trackSearch({ abort: () => {} }); + untrackSearch(); + + const next = jest.fn(); + const complete = jest.fn(); + waitUntilNextSessionCompletes$(sessionService).subscribe({ next, complete }); + expect(next).not.toBeCalled(); + + sessionService.start(); + expect(next).not.toBeCalled(); + + untrackSearch = sessionService.trackSearch({ abort: () => {} }); + untrackSearch(); + + expect(next).not.toBeCalled(); + jest.advanceTimersByTime(500); + expect(next).not.toBeCalled(); + jest.advanceTimersByTime(1000); + expect(next).toBeCalledTimes(1); + expect(complete).toBeCalled(); + }); +}); diff --git a/src/plugins/data/public/search/session/session_helpers.ts b/src/plugins/data/public/search/session/session_helpers.ts new file mode 100644 index 0000000000000..1f0a2da7e93f4 --- /dev/null +++ b/src/plugins/data/public/search/session/session_helpers.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { debounceTime, first, skipUntil } from 'rxjs/operators'; +import { ISessionService } from './session_service'; +import { SearchSessionState } from './search_session_state'; + +/** + * Options for {@link waitUntilNextSessionCompletes$} + */ +export interface WaitUntilNextSessionCompletesOptions { + /** + * For how long to wait between session state transitions before considering that session completed + */ + waitForIdle?: number; +} + +/** + * Creates an observable that emits when next search session completes. + * This utility is helpful to use in the application to delay some tasks until next session completes. + * + * @param sessionService - {@link ISessionService} + * @param opts - {@link WaitUntilNextSessionCompletesOptions} + */ +export function waitUntilNextSessionCompletes$( + sessionService: ISessionService, + { waitForIdle = 1000 }: WaitUntilNextSessionCompletesOptions = { waitForIdle: 1000 } +) { + return sessionService.state$.pipe( + // wait until new session starts + skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.None))), + // wait until new session starts loading + skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.Loading))), + // debounce to ignore quick switches from loading <-> completed. + // that could happen between sequential search requests inside a single session + debounceTime(waitForIdle), + // then wait until it finishes + first( + (state) => + state === SearchSessionState.Completed || state === SearchSessionState.BackgroundCompleted + ) + ); +} diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx index aa36323d11bcc..04bdb7a690268 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx @@ -25,7 +25,6 @@ export type IndexPatternSelectProps = Required< indexPatternId: string; fieldTypes?: string[]; onNoIndexPatterns?: () => void; - maxIndexPatterns?: number; }; export type IndexPatternSelectInternalProps = IndexPatternSelectProps & { @@ -42,10 +41,6 @@ interface IndexPatternSelectState { // Needed for React.lazy // eslint-disable-next-line import/no-default-export export default class IndexPatternSelect extends Component { - static defaultProps: { - maxIndexPatterns: 1000; - }; - private isMounted: boolean = false; state: IndexPatternSelectState; @@ -67,7 +62,7 @@ export default class IndexPatternSelect extends Component { - const { fieldTypes, onNoIndexPatterns, indexPatternService } = this.props; - const indexPatterns = await indexPatternService.find( - `${searchValue}*`, - this.props.maxIndexPatterns - ); + const isCurrentSearch = () => { + return this.isMounted && searchValue === this.state.searchValue; + }; - // We need this check to handle the case where search results come back in a different - // order than they were sent out. Only load results for the most recent search. - if (searchValue !== this.state.searchValue || !this.isMounted) { + const idsAndTitles = await this.props.indexPatternService.getIdsWithTitle(); + if (!isCurrentSearch()) { return; } - const options = indexPatterns - .filter((indexPattern) => { - return fieldTypes - ? indexPattern.fields.some((field) => { - return fieldTypes.includes(field.type); - }) - : true; - }) - .map((indexPattern) => { - return { - label: indexPattern.title, - value: indexPattern.id, - }; + const options = []; + for (let i = 0; i < idsAndTitles.length; i++) { + if (!idsAndTitles[i].title.toLowerCase().includes(searchValue.toLowerCase())) { + // index pattern excluded due to title not matching search + continue; + } + + if (this.props.fieldTypes) { + try { + const indexPattern = await this.props.indexPatternService.get(idsAndTitles[i].id); + if (!isCurrentSearch()) { + return; + } + const hasRequiredFieldTypes = indexPattern.fields.some((field) => { + return this.props.fieldTypes!.includes(field.type); + }); + if (!hasRequiredFieldTypes) { + continue; + } + } catch (err) { + // could not load index pattern, exclude it from list. + continue; + } + } + + options.push({ + label: idsAndTitles[i].title, + value: idsAndTitles[i].id, }); + + // Loading each index pattern object requires a network call so just find small number of matching index patterns + // Users can use 'searchValue' to further refine the list and locate their index pattern. + if (options.length > 15) { + break; + } + } + this.setState({ isLoading: false, options, }); - if (onNoIndexPatterns && searchValue === '' && options.length === 0) { - onNoIndexPatterns(); + if (this.props.onNoIndexPatterns && searchValue === '' && options.length === 0) { + this.props.onNoIndexPatterns(); } }, 300); diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index 466cc8c3de0b7..4e12f11668734 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -17,6 +17,16 @@ @include kbnThemeStyle('v8') { background-color: $euiFormBackgroundColor; + border-radius: $euiFormControlBorderRadius; + + &.kbnQueryBar__textareaWrap--hasPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &.kbnQueryBar__textareaWrap--hasAppend { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } } @@ -35,8 +45,16 @@ } @include kbnThemeStyle('v8') { - border-radius: 0; padding-bottom: $euiSizeS + 1px; + + &.kbnQueryBar__textarea--hasPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &.kbnQueryBar__textarea--hasAppend { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } &:not(.kbnQueryBar__textarea--autoHeight):not(:invalid) { diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 900a4ab7d7eb7..0f660f87266fd 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -682,7 +682,14 @@ export default class QueryStringInputUI extends Component { ); const inputClassName = classNames( 'kbnQueryBar__textarea', - this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null + this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null, + this.props.prepend ? 'kbnQueryBar__textarea--hasPrepend' : null, + !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textarea--hasAppend' : null + ); + const inputWrapClassName = classNames( + 'euiFormControlLayout__childrenWrapper kbnQueryBar__textareaWrap', + this.props.prepend ? 'kbnQueryBar__textareaWrap--hasPrepend' : null, + !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textareaWrap--hasAppend' : null ); return ( @@ -711,7 +718,7 @@ export default class QueryStringInputUI extends Component { >
> { }, [UI_SETTINGS.HISTOGRAM_BAR_TARGET]: { name: i18n.translate('data.advancedSettings.histogram.barTargetTitle', { - defaultMessage: 'Target bars', + defaultMessage: 'Target buckets', }), value: 50, description: i18n.translate('data.advancedSettings.histogram.barTargetText', { defaultMessage: - 'Attempt to generate around this many bars when using "auto" interval in date histograms', + 'Attempt to generate around this many buckets when using "auto" interval in date and numeric histograms', }), schema: schema.number(), }, [UI_SETTINGS.HISTOGRAM_MAX_BARS]: { name: i18n.translate('data.advancedSettings.histogram.maxBarsTitle', { - defaultMessage: 'Maximum bars', + defaultMessage: 'Maximum buckets', }), value: 100, description: i18n.translate('data.advancedSettings.histogram.maxBarsText', { - defaultMessage: - 'Never show more than this many bars in date histograms, scale values if needed', + defaultMessage: ` + Limits the density of date and number histograms across Kibana + for better performance using a test query. If the test query would too many buckets, + the interval between buckets will be increased. This setting applies separately + to each histogram aggregation, and does not apply to other types of aggregation. + To find the maximum value of this setting, divide the Elasticsearch 'search.max_buckets' + value by the maximum number of aggregations in each visualization. + `, }), schema: schema.number(), }, diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 2c80fc111c740..45382af098644 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { merge, Subject, Subscription } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; +import { debounceTime, tap, filter } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; @@ -25,8 +25,6 @@ import { discoverResponseHandler } from './response_handler'; import { getAngularModule, getHeaderActionMenuMounter, - getRequestInspectorStats, - getResponseInspectorStats, getServices, getUrlTracker, redirectWhenMissing, @@ -153,7 +151,6 @@ function discoverController($route, $scope) { const subscriptions = new Subscription(); const refetch$ = new Subject(); - let inspectorRequest; let isChangingIndexPattern = false; const savedSearch = $route.current.locals.savedObjects.savedSearch; const persistentSearchSource = savedSearch.searchSource; @@ -393,12 +390,11 @@ function discoverController($route, $scope) { $scope.state.index = $scope.indexPattern.id; $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); - $scope.opts.fetch = $scope.fetch = function () { + $scope.opts.fetch = $scope.fetch = async function () { $scope.fetchCounter++; $scope.fetchError = undefined; if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { $scope.resultState = 'none'; - return; } // Abort any in-progress requests before fetching again @@ -418,12 +414,14 @@ function discoverController($route, $scope) { $scope.fetchStatus = fetchStatuses.LOADING; $scope.resultState = getResultState($scope.fetchStatus, $scope.rows); - logInspectorRequest({ searchSessionId }); + return $scope.volatileSearchSource - .fetch({ + .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, + requestResponder: getRequestResponder({ searchSessionId }), }) + .toPromise() .then(onResults) .catch((error) => { // If the request was aborted then no need to surface this error in the UI @@ -440,10 +438,6 @@ function discoverController($route, $scope) { }; function onResults(resp) { - inspectorRequest - .stats(getResponseInspectorStats(resp, $scope.volatileSearchSource)) - .ok({ json: resp }); - if (getTimeField() && !$scope.state.hideChart) { const tabifiedData = tabifyAggResponse($scope.opts.chartAggConfigs, resp); $scope.volatileSearchSource.rawResponse = resp; @@ -464,7 +458,7 @@ function discoverController($route, $scope) { $scope.fetchStatus = fetchStatuses.COMPLETE; } - function logInspectorRequest({ searchSessionId = null } = { searchSessionId: null }) { + function getRequestResponder({ searchSessionId = null } = { searchSessionId: null }) { inspectorAdapters.requests.reset(); const title = i18n.translate('discover.inspectorRequestDataTitle', { defaultMessage: 'data', @@ -472,11 +466,7 @@ function discoverController($route, $scope) { const description = i18n.translate('discover.inspectorRequestDescription', { defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); - inspectorRequest = inspectorAdapters.requests.start(title, { description, searchSessionId }); - inspectorRequest.stats(getRequestInspectorStats($scope.volatileSearchSource)); - $scope.volatileSearchSource.getSearchRequestBody().then((body) => { - inspectorRequest.json(body); - }); + return inspectorAdapters.requests.start(title, { description, searchSessionId }); } $scope.resetQuery = function () { @@ -494,11 +484,19 @@ function discoverController($route, $scope) { showUnmappedFields, }; + // handler emitted by `timefilter.getAutoRefreshFetch$()` + // to notify when data completed loading and to start a new autorefresh loop + let autoRefreshDoneCb; const fetch$ = merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), - timefilter.getAutoRefreshFetch$(), + timefilter.getAutoRefreshFetch$().pipe( + tap((done) => { + autoRefreshDoneCb = done; + }), + filter(() => $scope.fetchStatus !== fetchStatuses.LOADING) + ), data.query.queryString.getUpdates$(), searchSessionManager.newSearchSessionIdFromURL$ ).pipe(debounceTime(100)); @@ -508,7 +506,16 @@ function discoverController($route, $scope) { $scope, fetch$, { - next: $scope.fetch, + next: async () => { + try { + await $scope.fetch(); + } finally { + // if there is a saved `autoRefreshDoneCb`, notify auto refresh service that + // the last fetch is completed so it starts the next auto refresh loop if needed + autoRefreshDoneCb?.(); + autoRefreshDoneCb = undefined; + } + }, }, (error) => addFatalError(core.fatalErrors, error) ) diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index e7349ed22355a..237da72ae3a52 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -29,13 +29,7 @@ import searchTemplateGrid from './search_template_datagrid.html'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; import { getSortForSearchSource } from '../angular/doc_table'; -import { - getRequestInspectorStats, - getResponseInspectorStats, - getServices, - IndexPattern, - ISearchSource, -} from '../../kibana_services'; +import { getServices, IndexPattern, ISearchSource } from '../../kibana_services'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { SavedSearch } from '../..'; import { @@ -330,14 +324,11 @@ export class SearchEmbeddable defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); - const inspectorRequest = this.inspectorAdapters.requests!.start(title, { + const requestResponder = this.inspectorAdapters.requests!.start(title, { description, searchSessionId, }); - inspectorRequest.stats(getRequestInspectorStats(searchSource)); - searchSource.getSearchRequestBody().then((body: Record) => { - inspectorRequest.json(body); - }); + this.searchScope.$apply(() => { this.searchScope!.isLoading = true; }); @@ -345,15 +336,15 @@ export class SearchEmbeddable try { // Make the request - const resp = await searchSource.fetch({ - abortSignal: this.abortController.signal, - sessionId: searchSessionId, - }); + const resp = await searchSource + .fetch$({ + abortSignal: this.abortController.signal, + sessionId: searchSessionId, + requestResponder, + }) + .toPromise(); this.updateOutput({ loading: false, error: undefined }); - // Log response to inspector - inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); - // Apply the changes to the angular scope this.searchScope.$apply(() => { this.searchScope!.hits = resp.hits.hits; diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index 27bcc00234939..e4b0035ed0e03 100644 --- a/src/plugins/discover/public/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -88,7 +88,7 @@ export const [getScopedHistory, setScopedHistory] = createGetterSetter> { + public getUpdated$(): Readonly> { return merge(this.getInput$().pipe(skip(1)), this.getOutput$().pipe(skip(1))).pipe( - debounceTime(0), - mapTo(undefined) + debounceTime(0) ); } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index b9719542adc81..3f0907acabdfa 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -282,7 +282,7 @@ export abstract class Embeddable>; + getUpdated$(): Readonly>; // (undocumented) readonly id: string; // (undocumented) diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 65925b5a2e4c2..4165b8906a20e 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -118,12 +118,15 @@ export class ExpressionLoader { return this.execution ? (this.execution.inspect() as Adapters) : undefined; } - update(expression?: string | ExpressionAstExpression, params?: IExpressionLoaderParams): void { + async update( + expression?: string | ExpressionAstExpression, + params?: IExpressionLoaderParams + ): Promise { this.setParams(params); this.loadingSubject.next(true); if (expression) { - this.loadData(expression, this.params); + await this.loadData(expression, this.params); } else if (this.data) { this.render(this.data); } diff --git a/src/plugins/kibana_usage_collection/tsconfig.json b/src/plugins/kibana_usage_collection/tsconfig.json index d664d936f6667..ee07dfe589e4a 100644 --- a/src/plugins/kibana_usage_collection/tsconfig.json +++ b/src/plugins/kibana_usage_collection/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "common/*", diff --git a/src/plugins/kibana_utils/common/errors/errors.ts b/src/plugins/kibana_utils/common/errors/errors.ts index 7a9495cc8f413..7f3efc6d9571f 100644 --- a/src/plugins/kibana_utils/common/errors/errors.ts +++ b/src/plugins/kibana_utils/common/errors/errors.ts @@ -32,7 +32,7 @@ export class DuplicateField extends KbnError { export class SavedObjectNotFound extends KbnError { public savedObjectType: string; public savedObjectId?: string; - constructor(type: string, id?: string, link?: string) { + constructor(type: string, id?: string, link?: string, customMessage?: string) { const idMsg = id ? ` (id: ${id})` : ''; let message = `Could not locate that ${type}${idMsg}`; @@ -40,13 +40,31 @@ export class SavedObjectNotFound extends KbnError { message += `, [click here to re-create it](${link})`; } - super(message); + super(customMessage || message); this.savedObjectType = type; this.savedObjectId = id; } } +/** + * A saved field doesn't exist anymore + */ +export class SavedFieldNotFound extends KbnError { + constructor(message: string) { + super(message); + } +} + +/** + * A saved field type isn't compatible with aggregation + */ +export class SavedFieldTypeInvalidForAgg extends KbnError { + constructor(message: string) { + super(message); + } +} + /** * This error is for scenarios where a saved object is detected that has invalid JSON properties. * There was a scenario where we were importing objects with double-encoded JSON, and the system diff --git a/src/plugins/telemetry/common/telemetry_config/index.ts b/src/plugins/telemetry/common/telemetry_config/index.ts index 84b6486f35b24..cc4ff102742d7 100644 --- a/src/plugins/telemetry/common/telemetry_config/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/index.ts @@ -9,7 +9,5 @@ export { getTelemetryOptIn } from './get_telemetry_opt_in'; export { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; export { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; -export { - getTelemetryFailureDetails, - TelemetryFailureDetails, -} from './get_telemetry_failure_details'; +export { getTelemetryFailureDetails } from './get_telemetry_failure_details'; +export type { TelemetryFailureDetails } from './get_telemetry_failure_details'; diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 6cca9bdf881dd..47ba7828eaec2 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -8,7 +8,7 @@ import { PluginInitializerContext } from 'kibana/public'; import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; -export { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; +export type { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryPlugin(initializerContext); diff --git a/src/plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts index debdf7515cd58..1c335426ffd03 100644 --- a/src/plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -13,7 +13,7 @@ import { configSchema, TelemetryConfigType } from './config'; export { FetcherTask } from './fetcher'; export { handleOldSettings } from './handle_old_settings'; -export { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; +export type { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; export const config: PluginConfigDescriptor = { schema: configSchema, @@ -34,9 +34,12 @@ export { constants }; export { getClusterUuids, getLocalStats, - TelemetryLocalStats, DATA_TELEMETRY_ID, + buildDataTelemetryPayload, +} from './telemetry_collection'; + +export type { + TelemetryLocalStats, DataTelemetryIndex, DataTelemetryPayload, - buildDataTelemetryPayload, } from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts index def1131dfb1a3..c93b7e872924b 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts @@ -7,10 +7,5 @@ */ export { DATA_TELEMETRY_ID } from './constants'; - -export { - getDataTelemetry, - buildDataTelemetryPayload, - DataTelemetryPayload, - DataTelemetryIndex, -} from './get_data_telemetry'; +export { getDataTelemetry, buildDataTelemetryPayload } from './get_data_telemetry'; +export type { DataTelemetryPayload, DataTelemetryIndex } from './get_data_telemetry'; diff --git a/src/plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts index 55f9c7f0e624c..151e89a11a192 100644 --- a/src/plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -6,12 +6,9 @@ * Side Public License, v 1. */ -export { - DATA_TELEMETRY_ID, - DataTelemetryIndex, - DataTelemetryPayload, - buildDataTelemetryPayload, -} from './get_data_telemetry'; -export { getLocalStats, TelemetryLocalStats } from './get_local_stats'; +export { DATA_TELEMETRY_ID, buildDataTelemetryPayload } from './get_data_telemetry'; +export type { DataTelemetryIndex, DataTelemetryPayload } from './get_data_telemetry'; +export { getLocalStats } from './get_local_stats'; +export type { TelemetryLocalStats } from './get_local_stats'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/server/telemetry_repository/index.ts index 4e3f046f7611f..594b53259a65f 100644 --- a/src/plugins/telemetry/server/telemetry_repository/index.ts +++ b/src/plugins/telemetry/server/telemetry_repository/index.ts @@ -8,7 +8,7 @@ export { getTelemetrySavedObject } from './get_telemetry_saved_object'; export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; -export { +export type { TelemetrySavedObject, TelemetrySavedObjectAttributes, } from '../../common/telemetry_config/types'; diff --git a/src/plugins/telemetry/tsconfig.json b/src/plugins/telemetry/tsconfig.json index bdced01d9eb6f..6629e479906c9 100644 --- a/src/plugins/telemetry/tsconfig.json +++ b/src/plugins/telemetry/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/**/*", diff --git a/src/plugins/telemetry_collection_manager/server/index.ts b/src/plugins/telemetry_collection_manager/server/index.ts index 77077b73cf8ad..c0cd124a132c0 100644 --- a/src/plugins/telemetry_collection_manager/server/index.ts +++ b/src/plugins/telemetry_collection_manager/server/index.ts @@ -16,7 +16,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryCollectionManagerPlugin(initializerContext); } -export { +export type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, StatsCollectionConfig, diff --git a/src/plugins/telemetry_collection_manager/tsconfig.json b/src/plugins/telemetry_collection_manager/tsconfig.json index 1bba81769f0dd..1329979860603 100644 --- a/src/plugins/telemetry_collection_manager/tsconfig.json +++ b/src/plugins/telemetry_collection_manager/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "server/**/*", diff --git a/src/plugins/telemetry_management_section/public/index.ts b/src/plugins/telemetry_management_section/public/index.ts index 28b04418f512d..db6ea17556ed3 100644 --- a/src/plugins/telemetry_management_section/public/index.ts +++ b/src/plugins/telemetry_management_section/public/index.ts @@ -10,7 +10,7 @@ import { TelemetryManagementSectionPlugin } from './plugin'; export { OptInExampleFlyout } from './components'; -export { TelemetryManagementSectionPluginSetup } from './plugin'; +export type { TelemetryManagementSectionPluginSetup } from './plugin'; export function plugin() { return new TelemetryManagementSectionPlugin(); } diff --git a/src/plugins/telemetry_management_section/tsconfig.json b/src/plugins/telemetry_management_section/tsconfig.json index 48e40814b8570..2daee868ac200 100644 --- a/src/plugins/telemetry_management_section/tsconfig.json +++ b/src/plugins/telemetry_management_section/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/*", diff --git a/src/plugins/usage_collection/public/index.ts b/src/plugins/usage_collection/public/index.ts index b9e0e0a8985b1..9b009b1d9e264 100644 --- a/src/plugins/usage_collection/public/index.ts +++ b/src/plugins/usage_collection/public/index.ts @@ -10,7 +10,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { UsageCollectionPlugin } from './plugin'; export { METRIC_TYPE } from '@kbn/analytics'; -export { UsageCollectionSetup, UsageCollectionStart } from './plugin'; +export type { UsageCollectionSetup, UsageCollectionStart } from './plugin'; export { TrackApplicationView } from './components'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index 5f48f9fb93813..d5e0d95659e58 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -export { CollectorSet, CollectorSetPublic } from './collector_set'; -export { - Collector, +export { CollectorSet } from './collector_set'; +export type { CollectorSetPublic } from './collector_set'; +export { Collector } from './collector'; +export type { AllowedSchemaTypes, AllowedSchemaNumberTypes, SchemaField, @@ -16,4 +17,5 @@ export { CollectorOptions, CollectorFetchContext, } from './collector'; -export { UsageCollector, UsageCollectorOptions } from './usage_collector'; +export { UsageCollector } from './usage_collector'; +export type { UsageCollectorOptions } from './usage_collector'; diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index dfc9d19b69646..dd9e6644a827d 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -9,17 +9,16 @@ import { PluginInitializerContext } from 'src/core/server'; import { UsageCollectionPlugin } from './plugin'; -export { +export { Collector } from './collector'; +export type { AllowedSchemaTypes, MakeSchemaFrom, SchemaField, CollectorOptions, UsageCollectorOptions, - Collector, CollectorFetchContext, } from './collector'; - -export { UsageCollectionSetup } from './plugin'; +export type { UsageCollectionSetup } from './plugin'; export { config } from './config'; export const plugin = (initializerContext: PluginInitializerContext) => new UsageCollectionPlugin(initializerContext); diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts index 1a60d84e7948c..7e3f4273bbea8 100644 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ b/src/plugins/usage_collection/server/usage_collection.mock.ts @@ -16,7 +16,8 @@ import { import { CollectorOptions, Collector, UsageCollector } from './collector'; import { UsageCollectionSetup, CollectorFetchContext } from './index'; -export { CollectorOptions, Collector }; +export type { CollectorOptions }; +export { Collector }; const logger = loggingSystemMock.createLogger(); diff --git a/src/plugins/usage_collection/tsconfig.json b/src/plugins/usage_collection/tsconfig.json index 96b2c4d37e17c..68a0853994e80 100644 --- a/src/plugins/usage_collection/tsconfig.json +++ b/src/plugins/usage_collection/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/*", diff --git a/src/plugins/vis_default_editor/public/_default.scss b/src/plugins/vis_default_editor/public/_default.scss index c412b9d915e55..56c6a0f0f63f6 100644 --- a/src/plugins/vis_default_editor/public/_default.scss +++ b/src/plugins/vis_default_editor/public/_default.scss @@ -1,6 +1,4 @@ .visEditor--default { - // height: 1px is in place to make editor children take their height in the parent - height: 1px; flex: 1 1 auto; display: flex; } @@ -80,6 +78,7 @@ .visEditor__collapsibleSidebar { width: 100% !important; // force the editor to take 100% width + flex-grow: 0; } .visEditor__collapsibleSidebar-isClosed { @@ -91,8 +90,10 @@ } .visEditor__visualization__wrapper { - // force the visualization to take 100% width and height. + // force the visualization to take 100% width. width: 100% !important; - height: 100% !important; + flex: 1; + display: flex; + flex-direction: column; } } diff --git a/src/plugins/vis_default_editor/public/components/controls/field.test.tsx b/src/plugins/vis_default_editor/public/components/controls/field.test.tsx index 94f767510c4bd..277804567c2b7 100644 --- a/src/plugins/vis_default_editor/public/components/controls/field.test.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/field.test.tsx @@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils'; import { mount, shallow, ReactWrapper } from 'enzyme'; import { EuiComboBoxProps, EuiComboBox } from '@elastic/eui'; -import { IAggConfig, IndexPatternField } from 'src/plugins/data/public'; +import { IAggConfig, IndexPatternField, AggParam } from 'src/plugins/data/public'; import { ComboBoxGroupedOptions } from '../../utils'; import { FieldParamEditor, FieldParamEditorProps } from './field'; import { EditorVisState } from '../sidebar/state/reducers'; @@ -42,7 +42,7 @@ describe('FieldParamEditor component', () => { setTouched = jest.fn(); onChange = jest.fn(); - field = { displayName: 'bytes' } as IndexPatternField; + field = { displayName: 'bytes', type: 'bytes' } as IndexPatternField; option = { label: 'bytes', target: field }; indexedFields = [ { @@ -52,7 +52,16 @@ describe('FieldParamEditor component', () => { ]; defaultProps = { - agg: {} as IAggConfig, + agg: { + type: { + params: [ + ({ + name: 'field', + filterFieldTypes: ['bytes'], + } as unknown) as AggParam, + ], + }, + } as IAggConfig, aggParam: { name: 'field', type: 'field', diff --git a/src/plugins/vis_default_editor/public/components/controls/field.tsx b/src/plugins/vis_default_editor/public/components/controls/field.tsx index 95843dc6ae3a8..f8db2d89888a2 100644 --- a/src/plugins/vis_default_editor/public/components/controls/field.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/field.tsx @@ -13,7 +13,13 @@ import useMount from 'react-use/lib/useMount'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AggParam, IAggConfig, IFieldParamType, IndexPatternField } from 'src/plugins/data/public'; +import { + AggParam, + IAggConfig, + IFieldParamType, + IndexPatternField, + KBN_FIELD_TYPES, +} from '../../../../../plugins/data/public'; import { formatListAsProse, parseCommaSeparatedList, useValidation } from './utils'; import { AggParamEditorProps } from '../agg_param_props'; import { ComboBoxGroupedOptions } from '../../utils'; @@ -55,6 +61,7 @@ function FieldParamEditor({ } }; const errors = customError ? [customError] : []; + let showErrorMessageImmediately = false; if (!indexedFields.length) { errors.push( @@ -69,9 +76,38 @@ function FieldParamEditor({ ); } + if (value && value.type === KBN_FIELD_TYPES.MISSING) { + errors.push( + i18n.translate('visDefaultEditor.controls.field.fieldIsNotExists', { + defaultMessage: + 'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.', + values: { + fieldParameter: value.name, + }, + }) + ); + showErrorMessageImmediately = true; + } else if ( + value && + !getFieldTypes(agg).find((type: string) => type === value.type || type === '*') + ) { + errors.push( + i18n.translate('visDefaultEditor.controls.field.invalidFieldForAggregation', { + defaultMessage: + 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with this aggregation. Please select a new field.', + values: { + fieldParameter: value?.name, + indexPatternTitle: agg.getIndexPattern && agg.getIndexPattern().title, + }, + }) + ); + showErrorMessageImmediately = true; + } + const isValid = !!value && !errors.length && !isDirty; // we show an error message right away if there is no compatible fields - const showErrorMessage = (showValidation || !indexedFields.length) && !isValid; + const showErrorMessage = + (showValidation || !indexedFields.length || showErrorMessageImmediately) && !isValid; useValidation(setValidity, isValid); useMount(() => { @@ -122,10 +158,14 @@ function FieldParamEditor({ } function getFieldTypesString(agg: IAggConfig) { + return formatListAsProse(getFieldTypes(agg), { inclusive: false }); +} + +function getFieldTypes(agg: IAggConfig) { const param = get(agg, 'type.params', []).find((p: AggParam) => p.name === 'field') || ({} as IFieldParamType); - return formatListAsProse(parseCommaSeparatedList(param.filterFieldTypes), { inclusive: false }); + return parseCommaSeparatedList(param.filterFieldTypes || []); } export { FieldParamEditor }; diff --git a/src/plugins/vis_type_timeseries/common/calculate_label.test.ts b/src/plugins/vis_type_timeseries/common/calculate_label.test.ts index d5277623a136d..eab9665436c01 100644 --- a/src/plugins/vis_type_timeseries/common/calculate_label.test.ts +++ b/src/plugins/vis_type_timeseries/common/calculate_label.test.ts @@ -8,6 +8,7 @@ import { calculateLabel } from './calculate_label'; import type { MetricsItemsSchema } from './types'; +import { SanitizedFieldType } from './types'; describe('calculateLabel(metric, metrics)', () => { test('returns the metric.alias if set', () => { @@ -82,4 +83,26 @@ describe('calculateLabel(metric, metrics)', () => { expect(label).toEqual('Derivative of Outbound Traffic'); }); + + test('should throw an error if field not found', () => { + const metric = ({ id: 2, type: 'max', field: 3 } as unknown) as MetricsItemsSchema; + const metrics = ([ + { id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' }, + metric, + ] as unknown) as MetricsItemsSchema[]; + const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }]; + + expect(() => calculateLabel(metric, metrics, fields)).toThrowError('Field "3" not found'); + }); + + test('should not throw an error if field not found (isThrowErrorOnFieldNotFound is false)', () => { + const metric = ({ id: 2, type: 'max', field: 3 } as unknown) as MetricsItemsSchema; + const metrics = ([ + { id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' }, + metric, + ] as unknown) as MetricsItemsSchema[]; + const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }]; + + expect(calculateLabel(metric, metrics, fields, false)).toBe('Max of 3'); + }); }); diff --git a/src/plugins/vis_type_timeseries/common/calculate_label.ts b/src/plugins/vis_type_timeseries/common/calculate_label.ts index 73b5d3f652644..bd1482e14f4f4 100644 --- a/src/plugins/vis_type_timeseries/common/calculate_label.ts +++ b/src/plugins/vis_type_timeseries/common/calculate_label.ts @@ -10,6 +10,7 @@ import { includes, startsWith } from 'lodash'; import { i18n } from '@kbn/i18n'; import { lookup } from './agg_lookup'; import { MetricsItemsSchema, SanitizedFieldType } from './types'; +import { extractFieldLabel } from './fields_utils'; const paths = [ 'cumulative_sum', @@ -26,14 +27,11 @@ const paths = [ 'positive_only', ]; -export const extractFieldLabel = (fields: SanitizedFieldType[], name: string) => { - return fields.find((f) => f.name === name)?.label ?? name; -}; - export const calculateLabel = ( metric: MetricsItemsSchema, metrics: MetricsItemsSchema[] = [], - fields: SanitizedFieldType[] = [] + fields: SanitizedFieldType[] = [], + isThrowErrorOnFieldNotFound: boolean = true ): string => { if (!metric) { return i18n.translate('visTypeTimeseries.calculateLabel.unknownLabel', { @@ -71,7 +69,7 @@ export const calculateLabel = ( if (metric.type === 'positive_rate') { return i18n.translate('visTypeTimeseries.calculateLabel.positiveRateLabel', { defaultMessage: 'Counter Rate of {field}', - values: { field: extractFieldLabel(fields, metric.field!) }, + values: { field: extractFieldLabel(fields, metric.field!, isThrowErrorOnFieldNotFound) }, }); } if (metric.type === 'static') { @@ -115,7 +113,7 @@ export const calculateLabel = ( defaultMessage: '{lookupMetricType} of {metricField}', values: { lookupMetricType: lookup[metric.type], - metricField: extractFieldLabel(fields, metric.field!), + metricField: extractFieldLabel(fields, metric.field!, isThrowErrorOnFieldNotFound), }, }); }; diff --git a/src/plugins/vis_type_timeseries/common/constants.ts b/src/plugins/vis_type_timeseries/common/constants.ts index 66617c8518985..1debfaf951e99 100644 --- a/src/plugins/vis_type_timeseries/common/constants.ts +++ b/src/plugins/vis_type_timeseries/common/constants.ts @@ -13,3 +13,4 @@ export const ROUTES = { VIS_DATA: '/api/metrics/vis/data', FIELDS: '/api/metrics/fields', }; +export const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.test.ts b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts index d1036aab2dc3e..9550697e22851 100644 --- a/src/plugins/vis_type_timeseries/common/fields_utils.test.ts +++ b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts @@ -7,7 +7,7 @@ */ import { toSanitizedFieldType } from './fields_utils'; -import type { FieldSpec, RuntimeField } from '../../data/common'; +import type { FieldSpec } from '../../data/common'; describe('fields_utils', () => { describe('toSanitizedFieldType', () => { @@ -34,17 +34,6 @@ describe('fields_utils', () => { `); }); - test('should filter runtime fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - runtimeField: {} as RuntimeField, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - test('should filter non-aggregatable fields', async () => { const fields: FieldSpec[] = [ { diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.ts b/src/plugins/vis_type_timeseries/common/fields_utils.ts index 04499d5320ab8..6a83dd323b3fd 100644 --- a/src/plugins/vis_type_timeseries/common/fields_utils.ts +++ b/src/plugins/vis_type_timeseries/common/fields_utils.ts @@ -6,17 +6,60 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { FieldSpec } from '../../data/common'; import { isNestedField } from '../../data/common'; -import { SanitizedFieldType } from './types'; +import { FetchedIndexPattern, SanitizedFieldType } from './types'; -export const toSanitizedFieldType = (fields: FieldSpec[]) => { - return fields - .filter( - (field) => - // Make sure to only include mapped fields, e.g. no index pattern runtime fields - !field.runtimeField && field.aggregatable && !isNestedField(field) - ) +export class FieldNotFoundError extends Error { + constructor(name: string) { + super( + i18n.translate('visTypeTimeseries.fields.fieldNotFound', { + defaultMessage: `Field "{field}" not found`, + values: { field: name }, + }) + ); + } + + public get name() { + return this.constructor.name; + } + + public get body() { + return this.message; + } +} + +export const extractFieldLabel = ( + fields: SanitizedFieldType[], + name: string, + isThrowErrorOnFieldNotFound: boolean = true +) => { + if (fields.length && name) { + const field = fields.find((f) => f.name === name); + + if (field) { + return field.label || field.name; + } + if (isThrowErrorOnFieldNotFound) { + throw new FieldNotFoundError(name); + } + } + return name; +}; + +export function validateField(name: string, index: FetchedIndexPattern) { + if (name && index.indexPattern) { + const field = index.indexPattern.fields.find((f) => f.name === name); + if (!field) { + throw new FieldNotFoundError(name); + } + } +} + +export const toSanitizedFieldType = (fields: FieldSpec[]) => + fields + .filter((field) => field.aggregatable && !isNestedField(field)) .map( (field) => ({ @@ -25,4 +68,3 @@ export const toSanitizedFieldType = (fields: FieldSpec[]) => { type: field.type, } as SanitizedFieldType) ); -}; diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts index 0428e6e80ae78..1111a9c525243 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -81,7 +81,7 @@ describe('fetchIndexPattern', () => { }); describe('text-based index', () => { - test('should return the Kibana index if it exists', async () => { + test('should return the Kibana index if it exists (fetchKibabaIndexForStringIndexes is true)', async () => { mockedIndices = [ { id: 'indexId', @@ -89,7 +89,9 @@ describe('fetchIndexPattern', () => { }, ] as IndexPattern[]; - const value = await fetchIndexPattern('indexTitle', indexPatternsService); + const value = await fetchIndexPattern('indexTitle', indexPatternsService, { + fetchKibabaIndexForStringIndexes: true, + }); expect(value).toMatchInlineSnapshot(` Object { @@ -102,8 +104,10 @@ describe('fetchIndexPattern', () => { `); }); - test('should return only indexPatternString if Kibana index does not exist', async () => { - const value = await fetchIndexPattern('indexTitle', indexPatternsService); + test('should return only indexPatternString if Kibana index does not exist (fetchKibabaIndexForStringIndexes is true)', async () => { + const value = await fetchIndexPattern('indexTitle', indexPatternsService, { + fetchKibabaIndexForStringIndexes: true, + }); expect(value).toMatchInlineSnapshot(` Object { diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts index af9f0750b2604..5dacad338e7a8 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts @@ -52,7 +52,12 @@ export const extractIndexPatternValues = ( export const fetchIndexPattern = async ( indexPatternValue: IndexPatternValue | undefined, - indexPatternsService: Pick + indexPatternsService: Pick, + options: { + fetchKibabaIndexForStringIndexes: boolean; + } = { + fetchKibabaIndexForStringIndexes: false, + } ): Promise => { let indexPattern: FetchedIndexPattern['indexPattern']; let indexPatternString: string = ''; @@ -61,13 +66,16 @@ export const fetchIndexPattern = async ( indexPattern = await indexPatternsService.getDefault(); } else { if (isStringTypeIndexPattern(indexPatternValue)) { - indexPattern = (await indexPatternsService.find(indexPatternValue)).find( - (index) => index.title === indexPatternValue - ); - + if (options.fetchKibabaIndexForStringIndexes) { + indexPattern = (await indexPatternsService.find(indexPatternValue)).find( + (index) => index.title === indexPatternValue + ); + } if (!indexPattern) { indexPatternString = indexPatternValue; } + + indexPatternString = indexPatternValue; } else if (indexPatternValue.id) { indexPattern = await indexPatternsService.get(indexPatternValue.id); } diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 74e247b7af06d..240b3e68cf65d 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -46,6 +46,7 @@ interface TableData { export type SeriesData = { type: Exclude; uiRestrictions: TimeseriesUIRestrictions; + error?: string; } & { [key: string]: PanelSeries; }; @@ -56,7 +57,7 @@ interface PanelSeries { }; id: string; series: PanelData[]; - error?: unknown; + error?: string; } export interface PanelData { @@ -66,6 +67,7 @@ export interface PanelData { seriesId: string; splitByLabel: string; isSplitByTerms: boolean; + error?: string; } export const isVisTableData = (data: TimeseriesVisData): data is TableData => diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx index 82989cc15d6c9..7d42eb3f40ac5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx @@ -5,19 +5,26 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui'; -import { METRIC_TYPES } from '../../../../common/metric_types'; +import React, { ReactNode, useContext } from 'react'; +import { + EuiComboBox, + EuiComboBoxProps, + EuiComboBoxOptionOption, + EuiFormRow, + htmlIdGenerator, +} from '@elastic/eui'; import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types'; import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; // @ts-ignore import { isFieldEnabled } from '../../lib/check_ui_restrictions'; +import { PanelModelContext } from '../../contexts/panel_model_context'; +import { USE_KIBANA_INDEXES_KEY } from '../../../../common/constants'; interface FieldSelectProps { + label: string | ReactNode; type: string; fields: Record; indexPattern: IndexPatternValue; @@ -45,6 +52,7 @@ const sortByLabel = (a: EuiComboBoxOptionOption, b: EuiComboBoxOptionOpt }; export function FieldSelect({ + label, type, fields, indexPattern = '', @@ -56,11 +64,10 @@ export function FieldSelect({ uiRestrictions, 'data-test-subj': dataTestSubj = 'metricsIndexPatternFieldsSelect', }: FieldSelectProps) { - if (type === METRIC_TYPES.COUNT) { - return null; - } + const panelModel = useContext(PanelModelContext); + const htmlId = htmlIdGenerator(); - const selectedOptions: Array> = []; + let selectedOptions: Array> = []; let newPlaceholder = placeholder; const fieldsSelector = getIndexPatternKey(indexPattern); @@ -112,19 +119,43 @@ export function FieldSelect({ } }); - if (value && !selectedOptions.length) { - onChange([]); + let isInvalid; + + if (Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY])) { + isInvalid = Boolean(value && fields[fieldsSelector] && !selectedOptions.length); + + if (value && !selectedOptions.length) { + selectedOptions = [{ label: value!, id: 'INVALID_FIELD' }]; + } + } else { + if (value && !selectedOptions.length) { + onChange([]); + } } return ( - + + + ); } diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js index 90353f9af8e35..c380b0e09e7d3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js @@ -153,24 +153,20 @@ export const FilterRatioAgg = (props) => { {model.metric_agg !== 'count' ? ( - } - > - - + fields={fields} + type={model.metric_agg} + restrict={getSupportedFieldsByMetricType(model.metric_agg)} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + /> ) : null} diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js index 964017cf886ec..7ce432a3bf676 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js @@ -70,7 +70,7 @@ export function MetricSelect(props) { const percentileOptions = siblings .filter((row) => /^percentile/.test(row.type)) .reduce((acc, row) => { - const label = calculateLabel(row, calculatedMetrics, fields); + const label = calculateLabel(row, calculatedMetrics, fields, false); switch (row.type) { case METRIC_TYPES.PERCENTILE_RANK: @@ -100,7 +100,7 @@ export function MetricSelect(props) { }, []); const options = siblings.filter(filterRows(includeSiblings)).map((row) => { - const label = calculateLabel(row, calculatedMetrics, fields); + const label = calculateLabel(row, calculatedMetrics, fields, false); return { value: row.id, label }; }); const allOptions = [...options, ...additionalOptions, ...percentileOptions]; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js index 77b2e2f020307..45bb5387c5cd3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js @@ -78,24 +78,20 @@ export function PercentileAgg(props) { /> - } - > - - + fields={fields} + type={model.type} + restrict={RESTRICT_FIELDS} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + /> { /> - } - > - - + fields={fields} + type={model.type} + restrict={RESTRICT_FIELDS} + indexPattern={indexPattern} + value={model.field ?? ''} + onChange={handleSelectChange('field')} + /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js index 4b1528ca27081..09d9f2f1a62f2 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js @@ -99,27 +99,22 @@ export const PositiveRateAgg = (props) => { /> - } + fields={props.fields} + type={model.type} + restrict={[KBN_FIELD_TYPES.NUMBER]} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + uiRestrictions={props.uiRestrictions} fullWidth - > - - + /> - } + fields={fields} + type={model.type} + restrict={restrictFields} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + uiRestrictions={uiRestrictions} fullWidth - > - - + /> ) : null} diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js index 749a97fa79f28..d4caa8a94652f 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js @@ -107,24 +107,20 @@ const StandardDeviationAggUi = (props) => { /> - } - > - - + fields={fields} + type={model.type} + restrict={RESTRICT_FIELDS} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + /> { /> - } - > - - + fields={fields} + type={model.type} + restrict={aggWithOptionsRestrictFields} + indexPattern={indexPattern} + value={model.field} + onChange={handleSelectChange('field')} + /> @@ -223,23 +219,19 @@ const TopHitAggUi = (props) => { - } - > - - + restrict={ORDER_DATE_RESTRICT_FIELDS} + value={model.order_by} + onChange={handleSelectChange('order_by')} + indexPattern={indexPattern} + fields={fields} + /> - } + restrict={RESTRICT_FIELDS} + value={model.time_field} + onChange={this.handleChange(model, 'time_field')} + indexPattern={model.index_pattern} + fields={this.props.fields} fullWidth - > - - + /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index 5a991238d10f8..e7a34c6e6596d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -77,8 +77,8 @@ export const IndexPattern = ({ const intervalName = `${prefix}interval`; const maxBarsName = `${prefix}max_bars`; const dropBucketName = `${prefix}drop_last_bucket`; - const defaultIndex = useContext(DefaultIndexPatternContext); const updateControlValidity = useContext(FormValidationContext); + const defaultIndex = useContext(DefaultIndexPatternContext); const uiRestrictions = get(useContext(VisDataContext), 'uiRestrictions'); const maxBarsUiSettings = config.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); @@ -192,22 +192,18 @@ export const IndexPattern = ({ /> - - - + restrict={RESTRICT_FIELDS} + value={model[timeFieldName]} + disabled={disabled} + onChange={handleSelectChange(timeFieldName)} + indexPattern={model[indexPatternName]} + fields={fields} + placeholder={!model[indexPatternName] ? defaultIndex?.timeFieldName : undefined} + /> - } - > - - + fields={this.props.fields} + value={model.pivot_id} + indexPattern={model.index_pattern} + onChange={this.handlePivotChange} + uiRestrictions={this.context.uiRestrictions} + type={BUCKET_TYPES.TERMS} + /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap b/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap index 09cd6d550fd96..562c463f6c83c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap @@ -26,13 +26,25 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js - } - labelType="label" - > - - + onChange={[Function]} + type="terms" + value="OriginCityName" + /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js index ab5342e925bd7..7db6a75e2392c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js @@ -110,8 +110,7 @@ export const SplitByTermsUI = ({ - } - > - - + data-test-subj="groupByField" + indexPattern={indexPattern} + onChange={handleSelectChange('terms_field')} + value={model.terms_field} + fields={fields} + uiRestrictions={uiRestrictions} + type={'terms'} + /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js index 0ba8d3e855365..1940ac8b2e9b9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js @@ -186,20 +186,16 @@ export class TableSeriesConfig extends Component { - } - > - - + fields={this.props.fields} + indexPattern={this.props.panel.index_pattern} + value={model.aggregate_by} + onChange={handleSelectChange('aggregate_by')} + fullWidth + /> { }); describe('text-based index', () => { - test('should return the Kibana index if it exists', async () => { - mockedIndices = [ - { - id: 'indexId', - title: 'indexTitle', - }, - ] as IndexPattern[]; - - const value = await cachedIndexPatternFetcher('indexTitle'); - - expect(value).toMatchInlineSnapshot(` - Object { - "indexPattern": Object { - "id": "indexId", - "title": "indexTitle", - }, - "indexPatternString": "indexTitle", - } - `); - }); - - test('should return only indexPatternString if Kibana index does not exist', async () => { + test('should return only indexPatternString', async () => { const value = await cachedIndexPatternFetcher('indexTitle'); expect(value).toMatchInlineSnapshot(` diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index 9003eb7fc2ced..4b13e62430c47 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -6,10 +6,13 @@ * Side Public License, v 1. */ +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; + import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; import type { AbstractSearchStrategy, DefaultSearchCapabilities } from '../index'; import type { IndexPatternsService } from '../../../../../data/common'; import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; +import type { IndexPatternValue } from '../../../../common/types'; export interface FieldsFetcherServices { indexPatternsService: IndexPatternsService; @@ -29,11 +32,13 @@ export const createFieldsFetcher = ( ) => { const fieldsCacheMap = new Map(); - return async (index: string) => { - if (fieldsCacheMap.has(index)) { - return fieldsCacheMap.get(index); + return async (indexPatternValue: IndexPatternValue) => { + const key = getIndexPatternKey(indexPatternValue); + + if (fieldsCacheMap.has(key)) { + return fieldsCacheMap.get(key); } - const fetchedIndex = await cachedIndexPatternFetcher(index); + const fetchedIndex = await cachedIndexPatternFetcher(indexPatternValue); const fields = await searchStrategy.getFieldsForWildcard( fetchedIndex, @@ -41,7 +46,7 @@ export const createFieldsFetcher = ( capabilities ); - fieldsCacheMap.set(index, fields); + fieldsCacheMap.set(key, fields); return fields; }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/build_request_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/build_request_body.ts index 5a84598bb5ed2..1350e56b68f59 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/build_request_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/build_request_body.ts @@ -7,8 +7,8 @@ */ import { IUiSettingsClient } from 'kibana/server'; -import { EsQueryConfig, IndexPattern } from 'src/plugins/data/server'; -import { AnnotationItemsSchema, PanelSchema } from '../../../../common/types'; +import { EsQueryConfig } from 'src/plugins/data/server'; +import { AnnotationItemsSchema, FetchedIndexPattern, PanelSchema } from '../../../../common/types'; import { VisTypeTimeseriesVisDataRequest } from '../../../types'; import { DefaultSearchCapabilities } from '../../search_strategies'; import { buildProcessorFunction } from '../build_processor_function'; @@ -17,16 +17,6 @@ import { processors } from '../request_processors/annotations'; /** * Builds annotation request body - * - * @param {...args}: [ - * req: {Object} - a request object, - * panel: {Object} - a panel object, - * annotation: {Object} - an annotation object, - * esQueryConfig: {Object} - es query config object, - * indexPatternObject: {Object} - an index pattern object, - * capabilities: {Object} - a search capabilities object - * ] - * @returns {Object} doc - processed body */ export async function buildAnnotationRequest( ...args: [ @@ -34,7 +24,7 @@ export async function buildAnnotationRequest( PanelSchema, AnnotationItemsSchema, EsQueryConfig, - IndexPattern | null | undefined, + FetchedIndexPattern, DefaultSearchCapabilities, IUiSettingsClient ] diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts index 32086fbf4f5b4..40f1b4f2cc051 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts @@ -33,24 +33,23 @@ export async function getAnnotationRequestParams( cachedIndexPatternFetcher, }: AnnotationServices ) { - const { indexPattern, indexPatternString } = await cachedIndexPatternFetcher( - annotation.index_pattern - ); + const annotationIndex = await cachedIndexPatternFetcher(annotation.index_pattern); const request = await buildAnnotationRequest( req, panel, annotation, esQueryConfig, - indexPattern, + annotationIndex, capabilities, uiSettings ); return { - index: indexPatternString, + index: annotationIndex.indexPatternString, body: { ...request, + runtime_mappings: annotationIndex.indexPattern?.getComputedFields().runtimeFields ?? {}, timeout: esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined, }, }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.ts similarity index 68% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.ts index ceb867e4e6d1e..7c0a0f5deb601 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.test.ts @@ -7,25 +7,30 @@ */ import { getIntervalAndTimefield } from './get_interval_and_timefield'; +import { FetchedIndexPattern, PanelSchema, SeriesItemsSchema } from '../../../common/types'; describe('getIntervalAndTimefield(panel, series)', () => { + const index: FetchedIndexPattern = {} as FetchedIndexPattern; + test('returns the panel interval and timefield', () => { - const panel = { time_field: '@timestamp', interval: 'auto' }; - const series = {}; - expect(getIntervalAndTimefield(panel, series)).toEqual({ + const panel = { time_field: '@timestamp', interval: 'auto' } as PanelSchema; + const series = {} as SeriesItemsSchema; + + expect(getIntervalAndTimefield(panel, series, index)).toEqual({ timeField: '@timestamp', interval: 'auto', }); }); test('returns the series interval and timefield', () => { - const panel = { time_field: '@timestamp', interval: 'auto' }; - const series = { + const panel = { time_field: '@timestamp', interval: 'auto' } as PanelSchema; + const series = ({ override_index_pattern: true, series_interval: '1m', series_time_field: 'time', - }; - expect(getIntervalAndTimefield(panel, series)).toEqual({ + } as unknown) as SeriesItemsSchema; + + expect(getIntervalAndTimefield(panel, series, index)).toEqual({ timeField: 'time', interval: '1m', }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts similarity index 57% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts index ebab984ff25aa..e3d0cec1a6939 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts @@ -7,28 +7,31 @@ */ import { AUTO_INTERVAL } from '../../../common/constants'; +import { FetchedIndexPattern, PanelSchema, SeriesItemsSchema } from '../../../common/types'; +import { validateField } from '../../../common/fields_utils'; -const DEFAULT_TIME_FIELD = '@timestamp'; - -export function getIntervalAndTimefield(panel, series = {}, indexPattern) { - const getDefaultTimeField = () => indexPattern?.timeFieldName ?? DEFAULT_TIME_FIELD; - +export function getIntervalAndTimefield( + panel: PanelSchema, + series: SeriesItemsSchema, + index: FetchedIndexPattern +) { const timeField = - (series.override_index_pattern && series.series_time_field) || - panel.time_field || - getDefaultTimeField(); + (series.override_index_pattern ? series.series_time_field : panel.time_field) || + index.indexPattern?.timeFieldName; + + validateField(timeField!, index); let interval = panel.interval; let maxBars = panel.max_bars; if (series.override_index_pattern) { - interval = series.series_interval; + interval = series.series_interval || AUTO_INTERVAL; maxBars = series.series_max_bars; } return { + maxBars, timeField, interval: interval || AUTO_INTERVAL, - maxBars, }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts index 0cc1188086b7b..b50fdb6b8226d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts @@ -18,7 +18,7 @@ import { handleErrorResponse } from './handle_error_response'; import { processBucket } from './table/process_bucket'; import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher'; -import { extractFieldLabel } from '../../../common/calculate_label'; +import { extractFieldLabel } from '../../../common/fields_utils'; import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequestServices, @@ -58,8 +58,8 @@ export async function getTableData( }); const calculatePivotLabel = async () => { - if (panel.pivot_id && panelIndex.indexPattern?.title) { - const fields = await extractFields(panelIndex.indexPattern.title); + if (panel.pivot_id && panelIndex.indexPattern?.id) { + const fields = await extractFields({ id: panelIndex.indexPattern.id }); return extractFieldLabel(fields, panel.pivot_id); } @@ -68,7 +68,6 @@ export async function getTableData( const meta = { type: panel.type, - pivot_label: panel.pivot_label || (await calculatePivotLabel()), uiRestrictions: capabilities.uiRestrictions, }; @@ -77,14 +76,17 @@ export async function getTableData( req, panel, services.esQueryConfig, - panelIndex.indexPattern, + panelIndex, capabilities, services.uiSettings ); const [resp] = await searchStrategy.search(requestContext, req, [ { - body, + body: { + ...body, + runtime_mappings: panelIndex.indexPattern?.getComputedFields().runtimeFields ?? {}, + }, index: panelIndex.indexPatternString, }, ]); @@ -101,6 +103,7 @@ export async function getTableData( return { ...meta, + pivot_label: panel.pivot_label || (await calculatePivotLabel()), series, }; } catch (err) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js index 268c26115233e..27e7c5c908b9a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js @@ -23,9 +23,8 @@ export async function getSplits(resp, panel, series, meta, extractFields) { const color = new Color(series.color); const metric = getLastMetric(series); const buckets = _.get(resp, `aggregations.${series.id}.buckets`); - - const fieldsForMetaIndex = meta.index ? await extractFields(meta.index) : []; - const splitByLabel = calculateLabel(metric, series.metrics, fieldsForMetaIndex); + const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : []; + const splitByLabel = calculateLabel(metric, series.metrics, fieldsForSeries); if (buckets) { if (Array.isArray(buckets)) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 22a475a9997a7..f3ee416be81a8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -10,6 +10,7 @@ import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { getTimerange } from '../../helpers/get_timerange'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; +import { validateField } from '../../../../../common/fields_utils'; const { dateHistogramInterval } = search.aggs; @@ -18,13 +19,16 @@ export function dateHistogram( panel, annotation, esQueryConfig, - indexPattern, + annotationIndex, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = annotation.time_field; + + validateField(timeField, annotationIndex); + const { bucketSize, intervalString } = getBucketSize( req, 'auto', diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index e7270371a3fdc..46a3c369e548d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -9,26 +9,30 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { getTimerange } from '../../helpers/get_timerange'; import { esQuery, UI_SETTINGS } from '../../../../../../data/server'; +import { validateField } from '../../../../../common/fields_utils'; export function query( req, panel, annotation, esQueryConfig, - indexPattern, + annotationIndex, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const timeField = annotation.time_field; + const timeField = (annotation.time_field || annotationIndex.indexPattern?.timeField) ?? ''; + + validateField(timeField, annotationIndex); + const { bucketSize } = getBucketSize(req, 'auto', capabilities, barTargetUiSettings); const { from, to } = getTimerange(req); doc.size = 0; const queries = !annotation.ignore_global_filters ? req.body.query : []; const filters = !annotation.ignore_global_filters ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(annotationIndex.indexPattern, queries, filters, esQueryConfig); const timerange = { range: { [timeField]: { @@ -42,13 +46,18 @@ export function query( if (annotation.query_string) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPattern, [annotation.query_string], [], esQueryConfig) + esQuery.buildEsQuery( + annotationIndex.indexPattern, + [annotation.query_string], + [], + esQueryConfig + ) ); } if (!annotation.ignore_panel_filters && panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(annotationIndex.indexPattern, [panel.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js index 2e759cb6b8b74..1b4434c4867c8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -7,12 +7,15 @@ */ import { overwrite } from '../../helpers'; +import { validateField } from '../../../../../common/fields_utils'; -export function topHits(req, panel, annotation) { +export function topHits(req, panel, annotation, esQueryConfig, annotationIndex) { return (next) => (doc) => { const fields = (annotation.fields && annotation.fields.split(/[,\s]+/)) || []; const timeField = annotation.time_field; + validateField(timeField, annotationIndex); + overwrite(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { sort: [ { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index a9b4f99fdb693..41ed472c31936 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -12,6 +12,7 @@ import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { isLastValueTimerangeMode } from '../../helpers/get_timerange_mode'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; + const { dateHistogramInterval } = search.aggs; export function dateHistogram( @@ -19,7 +20,7 @@ export function dateHistogram( panel, series, esQueryConfig, - indexPattern, + seriesIndex, capabilities, uiSettings ) { @@ -27,7 +28,7 @@ export function dateHistogram( const maxBarsUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, indexPattern); + const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, seriesIndex); const { bucketSize, intervalString } = getBucketSize( req, interval, @@ -64,9 +65,9 @@ export function dateHistogram( overwrite(doc, `aggs.${series.id}.meta`, { timeField, intervalString, - index: indexPattern?.title, bucketSize, seriesId: series.id, + index: seriesIndex.indexPattern?.id, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js index 4639af9db83b8..d45943f6f21ac 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -12,19 +12,19 @@ import { esQuery } from '../../../../../../data/server'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, series, esQueryConfig, indexPattern) { +export function ratios(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { if (series.metrics.some(filter)) { series.metrics.filter(filter).forEach((metric) => { overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js index 345488ec01d5e..a93827ba82cd6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js @@ -8,7 +8,7 @@ import { ratios } from './filter_ratios'; -describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => { +describe('ratios(req, panel, series, esQueryConfig, seriesIndex)', () => { let panel; let series; let req; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 86b691f6496c9..29a11bf163e0b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -17,14 +17,14 @@ export function metricBuckets( panel, series, esQueryConfig, - indexPattern, + seriesIndex, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPattern); + const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index ce61374c0b124..208321a98737e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -56,14 +56,14 @@ export function positiveRate( panel, series, esQueryConfig, - indexPattern, + seriesIndex, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPattern); + const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); if (series.metrics.some(filter)) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js index d0e92c9157cb5..a5f4e17289e06 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js @@ -10,16 +10,16 @@ import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, series, esQueryConfig, indexPattern) { +export function query(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, series, indexPattern); + const { timeField } = getIntervalAndTimefield(panel, series, seriesIndex); const { from, to } = offsetTime(req, series.offset_time); doc.size = 0; const ignoreGlobalFilter = panel.ignore_global_filter || series.ignore_global_filter; const queries = !ignoreGlobalFilter ? req.body.query : []; const filters = !ignoreGlobalFilter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(seriesIndex.indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -34,13 +34,13 @@ export function query(req, panel, series, esQueryConfig, indexPattern) { if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [panel.filter], [], esQueryConfig) ); } if (series.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [series.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.test.js index 2772aed822517..b3e88dbf1c6b9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.test.js @@ -8,15 +8,17 @@ import { query } from './query'; -describe('query(req, panel, series)', () => { +describe('query', () => { let panel; let series; let req; + let seriesIndex; const config = { allowLeadingWildcards: true, queryStringOptions: { analyze_wildcard: true }, }; + beforeEach(() => { req = { body: { @@ -32,17 +34,18 @@ describe('query(req, panel, series)', () => { interval: '10s', }; series = { id: 'test' }; + seriesIndex = {}; }); test('calls next when finished', () => { const next = jest.fn(); - query(req, panel, series, config)(next)({}); + query(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns doc with query for timerange', () => { const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -69,7 +72,7 @@ describe('query(req, panel, series)', () => { test('returns doc with query for timerange (offset by 1h)', () => { series.offset_time = '1h'; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -108,7 +111,7 @@ describe('query(req, panel, series)', () => { }, ]; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -147,7 +150,7 @@ describe('query(req, panel, series)', () => { test('returns doc with series filter', () => { series.filter = { query: 'host:web-server', language: 'lucene' }; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -201,7 +204,7 @@ describe('query(req, panel, series)', () => { ]; panel.filter = { query: 'host:web-server', language: 'lucene' }; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -269,7 +272,7 @@ describe('query(req, panel, series)', () => { panel.filter = { query: 'host:web-server', language: 'lucene' }; panel.ignore_global_filter = true; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { @@ -325,7 +328,7 @@ describe('query(req, panel, series)', () => { panel.filter = { query: 'host:web-server', language: 'lucene' }; series.ignore_global_filter = true; const next = (doc) => doc; - const doc = query(req, panel, series, config)(next)({}); + const doc = query(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ size: 0, query: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index 401344d48f865..dbeb3b1393bd5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -17,13 +17,13 @@ export function siblingBuckets( panel, series, esQueryConfig, - indexPattern, + seriesIndex, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPattern); + const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js index 25d62d4f7fe07..01e1b9f8d1dce 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js @@ -9,7 +9,7 @@ import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; -export function splitByFilter(req, panel, series, esQueryConfig, indexPattern) { +export function splitByFilter(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { if (series.split_mode !== 'filter') { return next(doc); @@ -18,7 +18,7 @@ export function splitByFilter(req, panel, series, esQueryConfig, indexPattern) { overwrite( doc, `aggs.${series.id}.filter`, - esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [series.filter], [], esQueryConfig) ); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.test.js index ad6e84dbc7842..9722833837167 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.test.js @@ -12,8 +12,12 @@ describe('splitByFilter(req, panel, series)', () => { let panel; let series; let req; + let config; + let seriesIndex; + beforeEach(() => { panel = {}; + config = {}; series = { id: 'test', split_mode: 'filter', @@ -27,17 +31,18 @@ describe('splitByFilter(req, panel, series)', () => { }, }, }; + seriesIndex = {}; }); test('calls next when finished', () => { const next = jest.fn(); - splitByFilter(req, panel, series)(next)({}); + splitByFilter(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns a valid filter with a query_string', () => { const next = (doc) => doc; - const doc = splitByFilter(req, panel, series)(next)({}); + const doc = splitByFilter(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -63,7 +68,7 @@ describe('splitByFilter(req, panel, series)', () => { test('calls next and does not add a filter', () => { series.split_mode = 'terms'; const next = jest.fn((doc) => doc); - const doc = splitByFilter(req, panel, series)(next)({}); + const doc = splitByFilter(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); expect(doc).toEqual({}); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js index 237ed16e5a8b6..77b9ccc5880fe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js @@ -9,11 +9,16 @@ import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; -export function splitByFilters(req, panel, series, esQueryConfig, indexPattern) { +export function splitByFilters(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { if (series.split_mode === 'filters' && series.split_filters) { series.split_filters.forEach((filter) => { - const builtEsQuery = esQuery.buildEsQuery(indexPattern, [filter.filter], [], esQueryConfig); + const builtEsQuery = esQuery.buildEsQuery( + seriesIndex.indexPattern, + [filter.filter], + [], + esQueryConfig + ); overwrite(doc, `aggs.${series.id}.filters.filters.${filter.id}`, builtEsQuery); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.test.js index fdcdfe45d2fd2..2a44bf2538a4b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.test.js @@ -12,7 +12,11 @@ describe('splitByFilters(req, panel, series)', () => { let panel; let series; let req; + let config; + let seriesIndex; + beforeEach(() => { + config = {}; panel = { time_field: 'timestamp', }; @@ -43,17 +47,18 @@ describe('splitByFilters(req, panel, series)', () => { }, }, }; + seriesIndex = {}; }); test('calls next when finished', () => { const next = jest.fn(); - splitByFilters(req, panel, series)(next)({}); + splitByFilters(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns a valid terms agg', () => { const next = (doc) => doc; - const doc = splitByFilters(req, panel, series)(next)({}); + const doc = splitByFilters(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -97,7 +102,7 @@ describe('splitByFilters(req, panel, series)', () => { test('calls next and does not add a terms agg', () => { series.split_mode = 'everything'; const next = jest.fn((doc) => doc); - const doc = splitByFilters(req, panel, series)(next)({}); + const doc = splitByFilters(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); expect(doc).toEqual({}); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js index 8f72bd2d12951..9c2bdbe03f886 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js @@ -10,13 +10,16 @@ import { overwrite } from '../../helpers'; import { basicAggs } from '../../../../../common/basic_aggs'; import { getBucketsPath } from '../../helpers/get_buckets_path'; import { bucketTransform } from '../../helpers/bucket_transform'; +import { validateField } from '../../../../../common/fields_utils'; -export function splitByTerms(req, panel, series) { +export function splitByTerms(req, panel, series, esQueryConfig, seriesIndex) { return (next) => (doc) => { if (series.split_mode === 'terms' && series.terms_field) { const termsField = series.terms_field; const orderByTerms = series.terms_order_by; + validateField(termsField, seriesIndex); + const direction = series.terms_direction || 'desc'; const metric = series.metrics.find((item) => item.id === orderByTerms); overwrite(doc, `aggs.${series.id}.terms.field`, termsField); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.test.js index 37d188c00eee3..984eb385ca4a6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.test.js @@ -8,11 +8,18 @@ import { splitByTerms } from './split_by_terms'; -describe('splitByTerms(req, panel, series)', () => { +describe('splitByTerms', () => { let panel; let series; let req; + let config; + let seriesIndex; + beforeEach(() => { + config = { + allowLeadingWildcards: true, + queryStringOptions: { analyze_wildcard: true }, + }; panel = { time_field: 'timestamp', }; @@ -31,17 +38,18 @@ describe('splitByTerms(req, panel, series)', () => { }, }, }; + seriesIndex = {}; }); test('calls next when finished', () => { const next = jest.fn(); - splitByTerms(req, panel, series)(next)({}); + splitByTerms(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns a valid terms agg', () => { const next = (doc) => doc; - const doc = splitByTerms(req, panel, series)(next)({}); + const doc = splitByTerms(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -61,7 +69,7 @@ describe('splitByTerms(req, panel, series)', () => { const next = (doc) => doc; series.terms_order_by = '_key'; series.terms_direction = 'asc'; - const doc = splitByTerms(req, panel, series)(next)({}); + const doc = splitByTerms(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -80,7 +88,7 @@ describe('splitByTerms(req, panel, series)', () => { test('returns a valid terms agg with custom sort', () => { series.terms_order_by = 'avgmetric'; const next = (doc) => doc; - const doc = splitByTerms(req, panel, series)(next)({}); + const doc = splitByTerms(req, panel, series, config, seriesIndex)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -106,7 +114,7 @@ describe('splitByTerms(req, panel, series)', () => { test('calls next and does not add a terms agg', () => { series.split_mode = 'everything'; const next = jest.fn((doc) => doc); - const doc = splitByTerms(req, panel, series)(next)({}); + const doc = splitByTerms(req, panel, series, config, seriesIndex)(next)({}); expect(next.mock.calls.length).toEqual(1); expect(doc).toEqual({}); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index aff1bd5041be5..4840e625383ca 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -13,15 +13,17 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { getTimerange } from '../../helpers/get_timerange'; import { calculateAggRoot } from './calculate_agg_root'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; + const { dateHistogramInterval } = search.aggs; -export function dateHistogram(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { +export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPattern); + const { timeField, interval } = getIntervalAndTimefield(panel, {}, seriesIndex); + const meta = { timeField, - index: indexPattern?.title, + index: seriesIndex.indexPattern?.id, }; const getDateHistogramForLastBucketMode = () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js index abb5971908771..e15330334639f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js @@ -13,7 +13,7 @@ import { calculateAggRoot } from './calculate_agg_root'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, esQueryConfig, indexPattern) { +export function ratios(req, panel, esQueryConfig, seriesIndex) { return (next) => (doc) => { panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -22,12 +22,12 @@ export function ratios(req, panel, esQueryConfig, indexPattern) { overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 5ce508bd9b279..421f9d2d75f0c 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -13,10 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { +export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); + const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index 176721e7b563a..3390362b56115 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -12,10 +12,10 @@ import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function positiveRate(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { +export function positiveRate(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); + const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js index 76df07b76e80e..66783e0cdfaef 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js @@ -10,16 +10,16 @@ import { getTimerange } from '../../helpers/get_timerange'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, esQueryConfig, indexPattern) { +export function query(req, panel, esQueryConfig, seriesIndex) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, {}, indexPattern); + const { timeField } = getIntervalAndTimefield(panel, {}, seriesIndex); const { from, to } = getTimerange(req); doc.size = 0; const queries = !panel.ignore_global_filter ? req.body.query : []; const filters = !panel.ignore_global_filter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(seriesIndex.indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -33,7 +33,7 @@ export function query(req, panel, esQueryConfig, indexPattern) { doc.query.bool.must.push(timerange); if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [panel.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 5539f16df41e0..9b4b0f244fc2c 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -13,10 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { +export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); + const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js index 595d49ebbd836..cda022294507f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js @@ -9,7 +9,7 @@ import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; -export function splitByEverything(req, panel, esQueryConfig, indexPattern) { +export function splitByEverything(req, panel, esQueryConfig, seriesIndex) { return (next) => (doc) => { panel.series .filter((c) => !(c.aggregate_by && c.aggregate_function)) @@ -18,7 +18,7 @@ export function splitByEverything(req, panel, esQueryConfig, indexPattern) { overwrite( doc, `aggs.pivot.aggs.${column.id}.filter`, - esQuery.buildEsQuery(indexPattern, [column.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [column.filter], [], esQueryConfig) ); } else { overwrite(doc, `aggs.pivot.aggs.${column.id}.filter.match_all`, {}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js index b4e07455be0fb..b3afc334ac2dd 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js @@ -9,7 +9,7 @@ import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; -export function splitByTerms(req, panel, esQueryConfig, indexPattern) { +export function splitByTerms(req, panel, esQueryConfig, seriesIndex) { return (next) => (doc) => { panel.series .filter((c) => c.aggregate_by && c.aggregate_function) @@ -21,7 +21,7 @@ export function splitByTerms(req, panel, esQueryConfig, indexPattern) { overwrite( doc, `aggs.pivot.aggs.${column.id}.column_filter.filter`, - esQuery.buildEsQuery(indexPattern, [column.filter], [], esQueryConfig) + esQuery.buildEsQuery(seriesIndex.indexPattern, [column.filter], [], esQueryConfig) ); } }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/series_agg.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/series_agg.js index ba0271ba286a1..a803439c7581f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/series_agg.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/series_agg.js @@ -5,9 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { last, first } from 'lodash'; import { SeriesAgg } from './_series_agg'; -import _ from 'lodash'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; import { calculateLabel } from '../../../../../common/calculate_label'; @@ -33,15 +32,14 @@ export function seriesAgg(resp, panel, series, meta, extractFields) { return (fn && fn(acc)) || acc; }, targetSeries); - const fieldsForMetaIndex = meta.index ? await extractFields(meta.index) : []; + const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : []; results.push({ id: `${series.id}`, label: - series.label || - calculateLabel(_.last(series.metrics), series.metrics, fieldsForMetaIndex), + series.label || calculateLabel(last(series.metrics), series.metrics, fieldsForSeries), color: series.color, - data: _.first(data), + data: first(data), ...decoration, }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/series_agg.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/series_agg.js index 9af05afd41182..ae4968e007b1d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/series_agg.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/series_agg.js @@ -7,7 +7,7 @@ */ import { SeriesAgg } from './_series_agg'; -import _ from 'lodash'; +import { last, first } from 'lodash'; import { calculateLabel } from '../../../../../common/calculate_label'; export function seriesAgg(resp, panel, series, meta, extractFields) { @@ -25,15 +25,13 @@ export function seriesAgg(resp, panel, series, meta, extractFields) { }); const fn = SeriesAgg[series.aggregate_function]; const data = fn(targetSeries); - - const fieldsForMetaIndex = meta.index ? await extractFields(meta.index) : []; + const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : []; results.push({ id: `${series.id}`, label: - series.label || - calculateLabel(_.last(series.metrics), series.metrics, fieldsForMetaIndex), - data: _.first(data), + series.label || calculateLabel(last(series.metrics), series.metrics, fieldsForSeries), + data: first(data), }); } return next(results); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.ts index bab3abe13bcb0..bc046cbdcf8aa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.ts @@ -18,7 +18,7 @@ import { processors } from '../request_processors/series/index'; * panel: {Object} - a panel object, * series: {Object} - an series object, * esQueryConfig: {Object} - es query config object, - * indexPatternObject: {Object} - an index pattern object, + * seriesIndex: {Object} - an index pattern object, * capabilities: {Object} - a search capabilities object * ] * @returns {Object} doc - processed body diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts index 1f2735da8fb06..827df30dacf6d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts @@ -39,7 +39,7 @@ export async function getSeriesRequestParams( panel, series, esQueryConfig, - seriesIndex.indexPattern, + seriesIndex, capabilities, uiSettings ); @@ -48,6 +48,7 @@ export async function getSeriesRequestParams( index: seriesIndex.indexPatternString, body: { ...request, + runtime_mappings: seriesIndex.indexPattern?.getComputedFields().runtimeFields ?? {}, timeout: esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined, }, }; diff --git a/src/plugins/vis_type_timeseries/server/ui_settings.ts b/src/plugins/vis_type_timeseries/server/ui_settings.ts index 9b7f6a1c832ef..07d2355b22253 100644 --- a/src/plugins/vis_type_timeseries/server/ui_settings.ts +++ b/src/plugins/vis_type_timeseries/server/ui_settings.ts @@ -16,11 +16,12 @@ import { MAX_BUCKETS_SETTING } from '../common/constants'; export const uiSettings: Record = { [MAX_BUCKETS_SETTING]: { name: i18n.translate('visTypeTimeseries.advancedSettings.maxBucketsTitle', { - defaultMessage: 'Maximum buckets', + defaultMessage: 'TSVB buckets limit', }), value: 2000, description: i18n.translate('visTypeTimeseries.advancedSettings.maxBucketsText', { - defaultMessage: 'The maximum number of buckets a single datasource can return', + defaultMessage: + 'Affects the TSVB histogram density. Must be set higher than "histogram:maxBars".', }), schema: schema.number(), }, diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap index 56f35ae021173..59a7cf966df91 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap @@ -54,6 +54,7 @@ exports[`ChartOptions component should init with the default set of props 1`] = { expect(setParamByIndex).toBeCalledWith('seriesParams', 0, paramName, ChartMode.Normal); }); + + it('should set "stacked" mode and disabled control if the referenced axis is "percentage"', () => { + defaultProps.valueAxes[0].scale.mode = AxisMode.Percentage; + defaultProps.chart.mode = ChartMode.Normal; + const paramName = 'mode'; + const comp = mount(); + + expect(setParamByIndex).toBeCalledWith('seriesParams', 0, paramName, ChartMode.Stacked); + expect(comp.find({ paramName }).prop('disabled')).toBeTruthy(); + }); }); diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx index 6f0b4fc5c9d22..23452a87aae60 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { SelectOption } from '../../../../../../vis_default_editor/public'; -import { SeriesParam, ValueAxis } from '../../../../types'; +import { SeriesParam, ValueAxis, ChartMode, AxisMode } from '../../../../types'; import { LineOptions } from './line_options'; import { SetParamByIndex, ChangeValueAxis } from '.'; import { ChartType } from '../../../../../common'; @@ -38,6 +38,7 @@ function ChartOptions({ changeValueAxis, setParamByIndex, }: ChartOptionsParams) { + const [disabledMode, setDisabledMode] = useState(false); const setChart: SetChart = useCallback( (paramName, value) => { setParamByIndex('seriesParams', index, paramName, value); @@ -68,6 +69,20 @@ function ChartOptions({ [valueAxes] ); + useEffect(() => { + const valueAxisToMetric = valueAxes.find((valueAxis) => valueAxis.id === chart.valueAxis); + if (valueAxisToMetric) { + if (valueAxisToMetric.scale.mode === AxisMode.Percentage) { + setDisabledMode(true); + if (chart.mode !== ChartMode.Stacked) { + setChart('mode', ChartMode.Stacked); + } + } else if (disabledMode) { + setDisabledMode(false); + } + } + }, [valueAxes, chart, disabledMode, setChart, setDisabledMode]); + return ( <> diff --git a/src/plugins/visualizations/public/components/visualization_container.tsx b/src/plugins/visualizations/public/components/visualization_container.tsx index 3081c39530d75..063715b6438eb 100644 --- a/src/plugins/visualizations/public/components/visualization_container.tsx +++ b/src/plugins/visualizations/public/components/visualization_container.tsx @@ -10,6 +10,7 @@ import React, { ReactNode, Suspense } from 'react'; import { EuiLoadingChart } from '@elastic/eui'; import classNames from 'classnames'; import { VisualizationNoResults } from './visualization_noresults'; +import { VisualizationError } from './visualization_error'; import { IInterpreterRenderHandlers } from '../../../expressions/common'; interface VisualizationContainerProps { @@ -18,6 +19,7 @@ interface VisualizationContainerProps { children: ReactNode; handlers: IInterpreterRenderHandlers; showNoResult?: boolean; + error?: string; } export const VisualizationContainer = ({ @@ -26,6 +28,7 @@ export const VisualizationContainer = ({ children, handlers, showNoResult = false, + error, }: VisualizationContainerProps) => { const classes = classNames('visualization', className); @@ -38,7 +41,13 @@ export const VisualizationContainer = ({ return (
- {showNoResult ? handlers.done()} /> : children} + {error ? ( + handlers.done()} error={error} /> + ) : showNoResult ? ( + handlers.done()} /> + ) : ( + children + )}
); diff --git a/src/plugins/visualizations/public/components/visualization_error.tsx b/src/plugins/visualizations/public/components/visualization_error.tsx new file mode 100644 index 0000000000000..81600a4e3601c --- /dev/null +++ b/src/plugins/visualizations/public/components/visualization_error.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; + +interface VisualizationNoResultsProps { + onInit?: () => void; + error: string; +} + +export class VisualizationError extends React.Component { + public render() { + return ( + {this.props.error}

} + /> + ); + } + + public componentDidMount() { + this.afterRender(); + } + + public componentDidUpdate() { + this.afterRender(); + } + + private afterRender() { + if (this.props.onInit) { + this.props.onInit(); + } + } +} diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 429dabeeef042..3bb52eb15758a 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -149,8 +149,9 @@ export class VisualizeEmbeddable } this.subscriptions.push( - this.getUpdated$().subscribe(() => { + this.getUpdated$().subscribe((value) => { const isDirty = this.handleChanges(); + if (isDirty && this.handler) { this.updateHandler(); } @@ -367,8 +368,8 @@ export class VisualizeEmbeddable } } - public reload = () => { - this.handleVisUpdate(); + public reload = async () => { + await this.handleVisUpdate(); }; private async updateHandler() { @@ -395,13 +396,13 @@ export class VisualizeEmbeddable }); if (this.handler && !abortController.signal.aborted) { - this.handler.update(this.expression, expressionParams); + await this.handler.update(this.expression, expressionParams); } } private handleVisUpdate = async () => { this.handleChanges(); - this.updateHandler(); + await this.updateHandler(); }; private uiStateChangeHandler = () => { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index 256e634ac6c40..f6ef1caf9c9e0 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -183,8 +183,12 @@ const TopNav = ({ useEffect(() => { const autoRefreshFetchSub = services.data.query.timefilter.timefilter .getAutoRefreshFetch$() - .subscribe(() => { - visInstance.embeddableHandler.reload(); + .subscribe(async (done) => { + try { + await visInstance.embeddableHandler.reload(); + } finally { + done(); + } }); return () => { autoRefreshFetchSub.unsubscribe(); diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index cc0f3ce2afae5..9eda709e58c3e 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -18,8 +18,17 @@ import { SavedObject } from 'src/plugins/saved_objects/public'; import { cloneDeep } from 'lodash'; import { ExpressionValueError } from 'src/plugins/expressions/public'; import { createSavedSearchesLoader } from '../../../../discover/public'; +import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '../../../../kibana_utils/common'; import { VisualizeServices } from '../types'; +function isErrorRelatedToRuntimeFields(error: ExpressionValueError['error']) { + const originalError = error.original || error; + return ( + originalError instanceof SavedFieldNotFound || + originalError instanceof SavedFieldTypeInvalidForAgg + ); +} + const createVisualizeEmbeddableAndLinkSavedSearch = async ( vis: Vis, visualizeServices: VisualizeServices @@ -37,7 +46,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( })) as VisualizeEmbeddableContract; embeddableHandler.getOutput$().subscribe((output) => { - if (output.error) { + if (output.error && !isErrorRelatedToRuntimeFields(output.error)) { data.search.showError( ((output.error as unknown) as ExpressionValueError['error']).original || output.error ); diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts index 64d61996495d7..965951bfbd88d 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts @@ -11,13 +11,12 @@ import { EventEmitter } from 'events'; import { parse } from 'query-string'; import { i18n } from '@kbn/i18n'; -import { redirectWhenMissing } from '../../../../../kibana_utils/public'; - import { getVisualizationInstance } from '../get_visualization_instance'; import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs'; import { SavedVisInstance, VisualizeServices, IEditorController } from '../../types'; import { VisualizeConstants } from '../../visualize_constants'; import { getVisEditorsRegistry } from '../../../services'; +import { redirectToSavedObjectPage } from '../utils'; /** * This effect is responsible for instantiating a saved vis or creating a new one @@ -43,9 +42,7 @@ export const useSavedVisInstance = ( chrome, history, dashboard, - setActiveUrl, toastNotifications, - http: { basePath }, stateTransferService, application: { navigateToApp }, } = services; @@ -131,27 +128,8 @@ export const useSavedVisInstance = ( visEditorController, }); } catch (error) { - const managementRedirectTarget = { - app: 'management', - path: `kibana/objects/savedVisualizations/${visualizationIdFromUrl}`, - }; - try { - redirectWhenMissing({ - history, - navigateToApp, - toastNotifications, - basePath, - mapping: { - visualization: VisualizeConstants.LANDING_PAGE_PATH, - search: managementRedirectTarget, - 'index-pattern': managementRedirectTarget, - 'index-pattern-field': managementRedirectTarget, - }, - onBeforeRedirect() { - setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); - }, - })(error); + redirectToSavedObjectPage(services, error, visualizationIdFromUrl); } catch (e) { toastNotifications.addWarning({ title: i18n.translate('visualize.createVisualization.failedToLoadErrorMessage', { diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts index 0e529507f97e3..c906ff5304c90 100644 --- a/src/plugins/visualize/public/application/utils/utils.ts +++ b/src/plugins/visualize/public/application/utils/utils.ts @@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n'; import { ChromeStart, DocLinksStart } from 'kibana/public'; import { Filter } from '../../../../data/public'; +import { redirectWhenMissing } from '../../../../kibana_utils/public'; +import { VisualizeConstants } from '../visualize_constants'; import { VisualizeServices, VisualizeEditorVisInstance } from '../types'; export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => { @@ -58,3 +60,36 @@ export const visStateToEditorState = ( linked: savedVis && savedVis.id ? !!savedVis.savedSearchId : !!savedVisState.savedSearchId, }; }; + +export const redirectToSavedObjectPage = ( + services: VisualizeServices, + error: any, + savedVisualizationsId?: string +) => { + const { + history, + setActiveUrl, + toastNotifications, + http: { basePath }, + application: { navigateToApp }, + } = services; + const managementRedirectTarget = { + app: 'management', + path: `kibana/objects/savedVisualizations/${savedVisualizationsId}`, + }; + redirectWhenMissing({ + history, + navigateToApp, + toastNotifications, + basePath, + mapping: { + visualization: VisualizeConstants.LANDING_PAGE_PATH, + search: managementRedirectTarget, + 'index-pattern': managementRedirectTarget, + 'index-pattern-field': managementRedirectTarget, + }, + onBeforeRedirect() { + setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); + }, + })(error); +}; diff --git a/test/common/services/index.ts b/test/common/services/index.ts index 7404bd1d7f46e..cc4859b7016bf 100644 --- a/test/common/services/index.ts +++ b/test/common/services/index.ts @@ -15,6 +15,7 @@ import { RetryProvider } from './retry'; import { RandomnessProvider } from './randomness'; import { SecurityServiceProvider } from './security'; import { EsDeleteAllIndicesProvider } from './es_delete_all_indices'; +import { SavedObjectInfoProvider } from './saved_object_info'; export const services = { deployment: DeploymentProvider, @@ -26,4 +27,5 @@ export const services = { randomness: RandomnessProvider, security: SecurityServiceProvider, esDeleteAllIndices: EsDeleteAllIndicesProvider, + savedObjectInfo: SavedObjectInfoProvider, }; diff --git a/test/common/services/saved_object_info.ts b/test/common/services/saved_object_info.ts new file mode 100644 index 0000000000000..02ab38d4ecb1d --- /dev/null +++ b/test/common/services/saved_object_info.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Client } from '@elastic/elasticsearch'; +import url from 'url'; +import { Either, fromNullable, chain, getOrElse } from 'fp-ts/Either'; +import { flow } from 'fp-ts/function'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const pluck = (key: string) => (obj: any): Either => + fromNullable(new Error(`Missing ${key}`))(obj[key]); + +const types = (node: string) => async (index: string = '.kibana') => { + let res: unknown; + try { + const { body } = await new Client({ node }).search({ + index, + body: { + aggs: { + savedobjs: { + terms: { + field: 'type', + }, + }, + }, + }, + }); + + res = flow( + pluck('aggregations'), + chain(pluck('savedobjs')), + chain(pluck('buckets')), + getOrElse((err) => `${err.message}`) + )(body); + } catch (err) { + throw new Error(`Error while searching for saved object types: ${err}`); + } + + return res; +}; + +export const SavedObjectInfoProvider: any = ({ getService }: FtrProviderContext) => { + const config = getService('config'); + + return { + types: types(url.format(config.get('servers.elasticsearch'))), + }; +}; diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index cc62608fbde6d..bf90d90cc828c 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -11,6 +11,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const savedObjectInfo = getService('savedObjectInfo'); const browser = getService('browser'); const log = getService('log'); const retry = getService('retry'); @@ -31,6 +32,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.savedObjects.clean({ types: ['search'] }); await kibanaServer.importExport.load('discover'); + log.info( + `\n### SAVED OBJECT TYPES IN index: [.kibana]: \n\t${await savedObjectInfo.types()}` + ); // and load a set of makelogs data await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 72deb74459ab9..e41422555f81d 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -21,9 +21,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/94532 - describe.skip('discover histogram', function describeIndexTests() { + describe('discover histogram', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('long_window_logstash'); @@ -107,8 +107,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); await testSubjects.click('discoverChartToggle'); - canvasExists = await elasticChart.canvasExists(); - expect(canvasExists).to.be(true); + await retry.waitFor(`Discover histogram to be displayed`, async () => { + canvasExists = await elasticChart.canvasExists(); + return canvasExists; + }); + await PageObjects.discover.saveSearch('persisted hidden histogram'); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/test/functional/apps/visualize/input_control_vis/input_control_options.ts b/test/functional/apps/visualize/input_control_vis/input_control_options.ts index dc02cada9a712..2e3b5d758436e 100644 --- a/test/functional/apps/visualize/input_control_vis/input_control_options.ts +++ b/test/functional/apps/visualize/input_control_vis/input_control_options.ts @@ -31,7 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.visEditor.clickVisEditorTab('controls'); await PageObjects.visEditor.addInputControl(); - await comboBox.set('indexPatternSelect-0', 'logstash- '); + await comboBox.set('indexPatternSelect-0', 'logstash-'); await comboBox.set('fieldSelect-0', FIELD_NAME); await PageObjects.visEditor.clickGo(); }); diff --git a/typings/elasticsearch/search.d.ts b/typings/elasticsearch/search.d.ts index fce08df1c0a04..c9bf3b1d8b7bc 100644 --- a/typings/elasticsearch/search.d.ts +++ b/typings/elasticsearch/search.d.ts @@ -370,6 +370,16 @@ export type AggregateOf< missing: { doc_count: number; } & SubAggregateOf; + multi_terms: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: Array< + { + doc_count: number; + key: string[]; + } & SubAggregateOf + >; + }; nested: { doc_count: number; } & SubAggregateOf; diff --git a/vars/retryable.groovy b/vars/retryable.groovy index ed84a00ece49d..bfd021ddd8167 100644 --- a/vars/retryable.groovy +++ b/vars/retryable.groovy @@ -48,7 +48,10 @@ def call(label, Closure closure) { try { closure() - } catch (ex) { + } catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException ex) { + // If the build was aborted, don't retry the step + throw ex + } catch (Exception ex) { if (haveReachedMaxRetries()) { print "Couldn't retry '${label}', have already reached the max number of retries for this build." throw ex diff --git a/x-pack/examples/alerting_example/server/plugin.ts b/x-pack/examples/alerting_example/server/plugin.ts index db9c996147c94..f6131679874db 100644 --- a/x-pack/examples/alerting_example/server/plugin.ts +++ b/x-pack/examples/alerting_example/server/plugin.ts @@ -33,7 +33,7 @@ export class AlertingExamplePlugin implements Plugin { + it('handles empty variables', () => { + expect(buildAlertHistoryDocument({})).toBeNull(); + }); + + it('returns null if rule type is not defined', () => { + expect(buildAlertHistoryDocument(getVariables({ rule: { type: undefined } }))).toBeNull(); + }); + + it('returns null if alert variables are not defined', () => { + expect(buildAlertHistoryDocument(getVariables({ alert: undefined }))).toBeNull(); + }); + + it('returns null if rule variables are not defined', () => { + expect(buildAlertHistoryDocument(getVariables({ rule: undefined }))).toBeNull(); + }); + + it('includes @timestamp field if date is null', () => { + const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ date: undefined })); + expect(alertHistoryDoc).not.toBeNull(); + expect(alertHistoryDoc!['@timestamp']).toBeTruthy(); + }); + + it(`doesn't include context if context is empty`, () => { + const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ context: {} })); + expect(alertHistoryDoc).not.toBeNull(); + expect(alertHistoryDoc!.kibana?.alert?.context).toBeFalsy(); + }); + + it(`doesn't include params if params is empty`, () => { + const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ params: {} })); + expect(alertHistoryDoc).not.toBeNull(); + expect(alertHistoryDoc!.rule?.params).toBeFalsy(); + }); + + it(`doesn't include tags if tags is empty array`, () => { + const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ tags: [] })); + expect(alertHistoryDoc).not.toBeNull(); + expect(alertHistoryDoc!.tags).toBeFalsy(); + }); + + it(`included message if context contains message`, () => { + const alertHistoryDoc = buildAlertHistoryDocument( + getVariables({ + context: { contextVar1: 'contextValue1', contextVar2: 'contextValue2', message: 'hello!' }, + }) + ); + expect(alertHistoryDoc).not.toBeNull(); + expect(alertHistoryDoc!.message).toEqual('hello!'); + }); + + it('builds alert history document from variables', () => { + expect(buildAlertHistoryDocument(getVariables())).toEqual({ + '@timestamp': '2021-01-01T00:00:00.000Z', + kibana: { + alert: { + actionGroup: 'action-group-id', + actionGroupName: 'Action Group', + context: { + 'rule-type': { + contextVar1: 'contextValue1', + contextVar2: 'contextValue2', + }, + }, + id: 'alert-id', + }, + }, + event: { + kind: 'alert', + }, + rule: { + id: 'rule-id', + name: 'rule-name', + params: { + 'rule-type': { + ruleParam: 1, + ruleParamString: 'another param', + }, + }, + space: 'space-id', + type: 'rule-type', + }, + tags: ['abc', 'def'], + }); + }); +}); diff --git a/x-pack/plugins/actions/common/alert_history_schema.ts b/x-pack/plugins/actions/common/alert_history_schema.ts new file mode 100644 index 0000000000000..e1c923ab23f44 --- /dev/null +++ b/x-pack/plugins/actions/common/alert_history_schema.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; + +export const ALERT_HISTORY_PREFIX = 'kibana-alert-history-'; +export const AlertHistoryDefaultIndexName = `${ALERT_HISTORY_PREFIX}default`; +export const AlertHistoryEsIndexConnectorId = 'preconfigured-alert-history-es-index'; + +export const buildAlertHistoryDocument = (variables: Record) => { + const { date, alert: alertVariables, context, params, tags, rule: ruleVariables } = variables as { + date: string; + alert: Record; + context: Record; + params: Record; + rule: Record; + tags: string[]; + }; + + if (!alertVariables || !ruleVariables) { + return null; + } + + const { actionGroup, actionGroupName, id: alertId } = alertVariables as { + actionGroup: string; + actionGroupName: string; + id: string; + }; + + const { id: ruleId, name, spaceId, type } = ruleVariables as { + id: string; + name: string; + spaceId: string; + type: string; + }; + + if (!type) { + // can't build the document without a type + return null; + } + + const ruleType = type.replace(/\./g, '__'); + + const rule = { + ...(ruleId ? { id: ruleId } : {}), + ...(name ? { name } : {}), + ...(!isEmpty(params) ? { params: { [ruleType]: params } } : {}), + ...(spaceId ? { space: spaceId } : {}), + ...(type ? { type } : {}), + }; + const alert = { + ...(alertId ? { id: alertId } : {}), + ...(!isEmpty(context) ? { context: { [ruleType]: context } } : {}), + ...(actionGroup ? { actionGroup } : {}), + ...(actionGroupName ? { actionGroupName } : {}), + }; + + const alertHistoryDoc = { + '@timestamp': date ? date : new Date().toISOString(), + ...(tags && tags.length > 0 ? { tags } : {}), + ...(context?.message ? { message: context.message } : {}), + ...(!isEmpty(rule) ? { rule } : {}), + ...(!isEmpty(alert) ? { kibana: { alert } } : {}), + }; + + return !isEmpty(alertHistoryDoc) ? { ...alertHistoryDoc, event: { kind: 'alert' } } : null; +}; + +export const AlertHistoryDocumentTemplate = Object.freeze( + buildAlertHistoryDocument({ + rule: { + id: '{{rule.id}}', + name: '{{rule.name}}', + type: '{{rule.type}}', + spaceId: '{{rule.spaceId}}', + }, + context: '{{context}}', + params: '{{params}}', + tags: '{{rule.tags}}', + alert: { + id: '{{alert.id}}', + actionGroup: '{{alert.actionGroup}}', + actionGroupName: '{{alert.actionGroupName}}', + }, + }) +); diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts index 184ae9c226b8f..336aa2263af0c 100644 --- a/x-pack/plugins/actions/common/index.ts +++ b/x-pack/plugins/actions/common/index.ts @@ -6,7 +6,7 @@ */ export * from './types'; +export * from './alert_history_schema'; +export * from './rewrite_request_case'; export const BASE_ACTION_API_PATH = '/api/actions'; - -export * from './rewrite_request_case'; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 6544a3c426e42..ae7faca1465c7 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -405,6 +405,7 @@ describe('create()', () => { enabled: true, enabledActionTypes: ['some-not-ignored-action-type'], allowedHosts: ['*'], + preconfiguredAlertHistoryEsIndex: false, preconfigured: {}, proxyRejectUnauthorizedCertificates: true, rejectUnauthorized: true, diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index c81f1f4a4bf2e..1b9de0162f340 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -18,6 +18,7 @@ const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, preconfigured: {}, proxyRejectUnauthorizedCertificates: true, rejectUnauthorized: true, diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index 282ff22f770f0..5c0f720e8c5fc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -18,6 +18,7 @@ import { ESIndexActionType, ESIndexActionTypeExecutorOptions, } from './es_index'; +import { AlertHistoryEsIndexConnectorId } from '../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; @@ -115,6 +116,7 @@ describe('params validation', () => { test('params validation succeeds when params is valid', () => { const params: Record = { documents: [{ rando: 'thing' }], + indexOverride: null, }; expect(validateParams(actionType, params)).toMatchInlineSnapshot(` Object { @@ -123,6 +125,7 @@ describe('params validation', () => { "rando": "thing", }, ], + "indexOverride": null, } `); }); @@ -159,6 +162,7 @@ describe('execute()', () => { config = { index: 'index-value', refresh: false, executionTimeField: null }; params = { documents: [{ jim: 'bob' }], + indexOverride: null, }; const actionId = 'some-id'; @@ -200,6 +204,7 @@ describe('execute()', () => { config = { index: 'index-value', executionTimeField: 'field_to_use_for_time', refresh: true }; params = { documents: [{ jimbob: 'jr' }], + indexOverride: null, }; executorOptions = { actionId, config, secrets, params, services }; @@ -237,6 +242,7 @@ describe('execute()', () => { config = { index: 'index-value', executionTimeField: null, refresh: false }; params = { documents: [{ jim: 'bob' }], + indexOverride: null, }; executorOptions = { actionId, config, secrets, params, services }; @@ -270,6 +276,7 @@ describe('execute()', () => { config = { index: 'index-value', executionTimeField: null, refresh: false }; params = { documents: [{ a: 1 }, { b: 2 }], + indexOverride: null, }; executorOptions = { actionId, config, secrets, params, services }; @@ -305,12 +312,244 @@ describe('execute()', () => { `); }); + test('renders parameter templates as expected', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + documents: [{ hello: '{{who}}' }], + indexOverride: null, + }; + const variables = { + who: 'world', + }; + const renderedParams = actionType.renderParameterTemplates!( + paramsWithTemplates, + variables, + 'action-type-id' + ); + expect(renderedParams).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "hello": "world", + }, + ], + "indexOverride": null, + } + `); + }); + + test('ignores indexOverride for generic es index connector', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + documents: [{ hello: '{{who}}' }], + indexOverride: 'hello-world', + }; + const variables = { + who: 'world', + }; + const renderedParams = actionType.renderParameterTemplates!( + paramsWithTemplates, + variables, + 'action-type-id' + ); + expect(renderedParams).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "hello": "world", + }, + ], + "indexOverride": null, + } + `); + }); + + test('renders parameter templates as expected for preconfigured alert history connector', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + documents: [{ hello: '{{who}}' }], + indexOverride: null, + }; + const variables = { + date: '2021-01-01T00:00:00.000Z', + rule: { + id: 'rule-id', + name: 'rule-name', + type: 'rule-type', + }, + context: { + contextVar1: 'contextValue1', + contextVar2: 'contextValue2', + }, + params: { + ruleParam: 1, + ruleParamString: 'another param', + }, + tags: ['abc', 'xyz'], + alert: { + id: 'alert-id', + actionGroup: 'action-group-id', + actionGroupName: 'Action Group', + }, + state: { + alertStateValue: true, + alertStateAnotherValue: 'yes', + }, + }; + const renderedParams = actionType.renderParameterTemplates!( + paramsWithTemplates, + variables, + AlertHistoryEsIndexConnectorId + ); + expect(renderedParams).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "@timestamp": "2021-01-01T00:00:00.000Z", + "event": Object { + "kind": "alert", + }, + "kibana": Object { + "alert": Object { + "actionGroup": "action-group-id", + "actionGroupName": "Action Group", + "context": Object { + "rule-type": Object { + "contextVar1": "contextValue1", + "contextVar2": "contextValue2", + }, + }, + "id": "alert-id", + }, + }, + "rule": Object { + "id": "rule-id", + "name": "rule-name", + "params": Object { + "rule-type": Object { + "ruleParam": 1, + "ruleParamString": "another param", + }, + }, + "type": "rule-type", + }, + "tags": Array [ + "abc", + "xyz", + ], + }, + ], + "indexOverride": null, + } + `); + }); + + test('passes through indexOverride for preconfigured alert history connector', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + documents: [{ hello: '{{who}}' }], + indexOverride: 'hello-world', + }; + const variables = { + date: '2021-01-01T00:00:00.000Z', + rule: { + id: 'rule-id', + name: 'rule-name', + type: 'rule-type', + }, + context: { + contextVar1: 'contextValue1', + contextVar2: 'contextValue2', + }, + params: { + ruleParam: 1, + ruleParamString: 'another param', + }, + tags: ['abc', 'xyz'], + alert: { + id: 'alert-id', + actionGroup: 'action-group-id', + actionGroupName: 'Action Group', + }, + state: { + alertStateValue: true, + alertStateAnotherValue: 'yes', + }, + }; + const renderedParams = actionType.renderParameterTemplates!( + paramsWithTemplates, + variables, + AlertHistoryEsIndexConnectorId + ); + expect(renderedParams).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "@timestamp": "2021-01-01T00:00:00.000Z", + "event": Object { + "kind": "alert", + }, + "kibana": Object { + "alert": Object { + "actionGroup": "action-group-id", + "actionGroupName": "Action Group", + "context": Object { + "rule-type": Object { + "contextVar1": "contextValue1", + "contextVar2": "contextValue2", + }, + }, + "id": "alert-id", + }, + }, + "rule": Object { + "id": "rule-id", + "name": "rule-name", + "params": Object { + "rule-type": Object { + "ruleParam": 1, + "ruleParamString": "another param", + }, + }, + "type": "rule-type", + }, + "tags": Array [ + "abc", + "xyz", + ], + }, + ], + "indexOverride": "hello-world", + } + `); + }); + + test('throws error for preconfigured alert history index when no variables are available', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + documents: [{ hello: '{{who}}' }], + indexOverride: null, + }; + const variables = {}; + + expect(() => + actionType.renderParameterTemplates!( + paramsWithTemplates, + variables, + AlertHistoryEsIndexConnectorId + ) + ).toThrowErrorMatchingInlineSnapshot( + `"error creating alert history document for ${AlertHistoryEsIndexConnectorId} connector"` + ); + }); + test('resolves with an error when an error occurs in the indexing operation', async () => { const secrets = {}; // minimal params const config = { index: 'index-value', refresh: false, executionTimeField: null }; const params = { documents: [{ '': 'bob' }], + indexOverride: null, }; const actionId = 'some-id'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index f7b0e7de478d8..3662fea00e31d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -8,9 +8,11 @@ import { curry, find } from 'lodash'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; - import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; +import { renderMustacheObject } from '../lib/mustache_renderer'; +import { buildAlertHistoryDocument, AlertHistoryEsIndexConnectorId } from '../../common'; +import { ALERT_HISTORY_PREFIX } from '../../common/alert_history_schema'; export type ESIndexActionType = ActionType; export type ESIndexActionTypeExecutorOptions = ActionTypeExecutorOptions< @@ -38,6 +40,15 @@ export type ActionParamsType = TypeOf; // eventually: https://github.com/elastic/kibana/projects/26#card-24087404 const ParamsSchema = schema.object({ documents: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + indexOverride: schema.nullable( + schema.string({ + validate: (pattern) => { + if (!pattern.startsWith(ALERT_HISTORY_PREFIX)) { + return `index must start with "${ALERT_HISTORY_PREFIX}"`; + } + }, + }) + ), }); export const ActionTypeId = '.index'; @@ -54,6 +65,7 @@ export function getActionType({ logger }: { logger: Logger }): ESIndexActionType params: ParamsSchema, }, executor: curry(executor)({ logger }), + renderParameterTemplates, }; } @@ -68,7 +80,7 @@ async function executor( const params = execOptions.params; const services = execOptions.services; - const index = config.index; + const index = params.indexOverride || config.index; const bulkBody = []; for (const document of params.documents) { @@ -107,6 +119,24 @@ async function executor( } } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record, + actionId: string +): ActionParamsType { + const { documents, indexOverride } = renderMustacheObject(params, variables); + + if (actionId === AlertHistoryEsIndexConnectorId) { + const alertHistoryDoc = buildAlertHistoryDocument(variables); + if (!alertHistoryDoc) { + throw new Error(`error creating alert history document for ${actionId} connector`); + } + return { documents: [alertHistoryDoc], indexOverride }; + } + + return { documents, indexOverride: null }; +} + function wrapErr( errMessage: string, actionId: string, diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 2eecaa19da0c5..ad598bffe04b4 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -31,6 +31,7 @@ describe('config validation', () => { "valueInBytes": 1048576, }, "preconfigured": Object {}, + "preconfiguredAlertHistoryEsIndex": false, "proxyRejectUnauthorizedCertificates": true, "rejectUnauthorized": true, "responseTimeout": "PT1M", @@ -74,6 +75,7 @@ describe('config validation', () => { "secrets": Object {}, }, }, + "preconfiguredAlertHistoryEsIndex": false, "proxyRejectUnauthorizedCertificates": false, "rejectUnauthorized": false, "responseTimeout": "PT1M", diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 4aa77ded315b8..36948478816c9 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -37,6 +37,7 @@ export const configSchema = schema.object({ defaultValue: [AllowedHosts.Any], } ), + preconfiguredAlertHistoryEsIndex: schema.boolean({ defaultValue: false }), preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, { defaultValue: {}, validate: validatePreconfigured, diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ab29f524c202d..4d32c2e2bf16d 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -40,10 +40,11 @@ const createStartMock = () => { // this is a default renderer that escapes nothing export function renderActionParameterTemplatesDefault( actionTypeId: string, + actionId: string, params: Record, variables: Record ) { - return renderActionParameterTemplates(undefined, actionTypeId, params, variables); + return renderActionParameterTemplates(undefined, actionTypeId, actionId, params, variables); } const createServicesMock = () => { diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 30bbedbedbe9c..3485891a01267 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -23,6 +23,7 @@ import { ActionsPluginsStart, PluginSetupContract, } from './plugin'; +import { AlertHistoryEsIndexConnectorId } from '../common'; describe('Actions Plugin', () => { describe('setup()', () => { @@ -36,6 +37,7 @@ describe('Actions Plugin', () => { enabled: true, enabledActionTypes: ['*'], allowedHosts: ['*'], + preconfiguredAlertHistoryEsIndex: false, preconfigured: {}, proxyRejectUnauthorizedCertificates: true, rejectUnauthorized: true, @@ -180,6 +182,7 @@ describe('Actions Plugin', () => { }); describe('start()', () => { + let context: PluginInitializerContext; let plugin: ActionsPlugin; let coreSetup: ReturnType; let coreStart: ReturnType; @@ -187,10 +190,11 @@ describe('Actions Plugin', () => { let pluginsStart: jest.Mocked; beforeEach(() => { - const context = coreMock.createPluginInitializerContext({ + context = coreMock.createPluginInitializerContext({ enabled: true, enabledActionTypes: ['*'], allowedHosts: ['*'], + preconfiguredAlertHistoryEsIndex: false, preconfigured: { preconfiguredServerLog: { actionTypeId: '.server-log', @@ -223,15 +227,6 @@ describe('Actions Plugin', () => { }); describe('getActionsClientWithRequest()', () => { - it('should handle preconfigured actions', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, pluginsSetup); - const pluginStart = await plugin.start(coreStart, pluginsStart); - - expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true); - }); - it('should not throw error when ESO plugin has encryption key', async () => { await plugin.setup(coreSetup, { ...pluginsSetup, @@ -258,6 +253,99 @@ describe('Actions Plugin', () => { }); }); + describe('Preconfigured connectors', () => { + function getConfig(overrides = {}) { + return { + enabled: true, + enabledActionTypes: ['*'], + allowedHosts: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: { + preconfiguredServerLog: { + actionTypeId: '.server-log', + name: 'preconfigured-server-log', + config: {}, + secrets: {}, + }, + }, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration('60s'), + ...overrides, + }; + } + + function setup(config: ActionsConfig) { + context = coreMock.createPluginInitializerContext(config); + plugin = new ActionsPlugin(context); + coreSetup = coreMock.createSetup(); + coreStart = coreMock.createStart(); + pluginsSetup = { + taskManager: taskManagerMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + licensing: licensingMock.createSetup(), + eventLog: eventLogMock.createSetup(), + usageCollection: usageCollectionPluginMock.createSetupContract(), + features: featuresPluginMock.createSetup(), + }; + pluginsStart = { + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + }; + } + + it('should handle preconfigured actions', async () => { + setup(getConfig()); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.setup(coreSetup as any, pluginsSetup); + const pluginStart = await plugin.start(coreStart, pluginsStart); + + expect(pluginStart.preconfiguredActions.length).toEqual(1); + expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true); + }); + + it('should handle preconfiguredAlertHistoryEsIndex = true', async () => { + setup(getConfig({ preconfiguredAlertHistoryEsIndex: true })); + + await plugin.setup(coreSetup, pluginsSetup); + const pluginStart = await plugin.start(coreStart, pluginsStart); + + expect(pluginStart.preconfiguredActions.length).toEqual(2); + expect( + pluginStart.isActionExecutable('preconfigured-alert-history-es-index', '.index') + ).toBe(true); + }); + + it('should not allow preconfigured connector with same ID as AlertHistoryEsIndexConnectorId', async () => { + setup( + getConfig({ + preconfigured: { + [AlertHistoryEsIndexConnectorId]: { + actionTypeId: '.index', + name: 'clashing preconfigured index connector', + config: {}, + secrets: {}, + }, + }, + }) + ); + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.setup(coreSetup as any, pluginsSetup); + const pluginStart = await plugin.start(coreStart, pluginsStart); + + expect(pluginStart.preconfiguredActions.length).toEqual(0); + expect(context.logger.get().warn).toHaveBeenCalledWith( + `Preconfigured connectors cannot have the id "${AlertHistoryEsIndexConnectorId}" because this is a reserved id.` + ); + }); + }); + describe('isActionTypeEnabled()', () => { const actionType: ActionType = { id: 'my-action-type', diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index bfe3b0a09ff2e..3c754d90c4af5 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -68,6 +68,9 @@ import { } from './authorization/get_authorization_mode_by_source'; import { ensureSufficientLicense } from './lib/ensure_sufficient_license'; import { renderMustacheObject } from './lib/mustache_renderer'; +import { getAlertHistoryEsIndex } from './preconfigured_connectors/alert_history_es_index/alert_history_es_index'; +import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/alert_history_es_index/create_alert_history_index_template'; +import { AlertHistoryEsIndexConnectorId } from '../common'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -98,6 +101,7 @@ export interface PluginStartContract { preconfiguredActions: PreConfiguredAction[]; renderActionParameterTemplates( actionTypeId: string, + actionId: string, params: Params, variables: Record ): Params; @@ -178,12 +182,22 @@ export class ActionsPlugin implements Plugin { return this.actionTypeRegistry!.isActionTypeEnabled(id, options); @@ -468,12 +489,13 @@ export class ActionsPlugin implements Plugin( actionTypeRegistry: ActionTypeRegistry | undefined, actionTypeId: string, + actionId: string, params: Params, variables: Record ): Params { const actionType = actionTypeRegistry?.get(actionTypeId); if (actionType?.renderParameterTemplates) { - return actionType.renderParameterTemplates(params, variables) as Params; + return actionType.renderParameterTemplates(params, variables, actionId) as Params; } else { return renderMustacheObject(params, variables); } diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts new file mode 100644 index 0000000000000..38556591c4ea2 --- /dev/null +++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { PreConfiguredAction } from '../../types'; +import { ActionTypeId as EsIndexActionTypeId } from '../../builtin_action_types/es_index'; +import { AlertHistoryEsIndexConnectorId, AlertHistoryDefaultIndexName } from '../../../common'; + +export function getAlertHistoryEsIndex(): Readonly { + return Object.freeze({ + name: i18n.translate('xpack.actions.alertHistoryEsIndexConnector.name', { + defaultMessage: 'Alert history Elasticsearch index', + }), + actionTypeId: EsIndexActionTypeId, + id: AlertHistoryEsIndexConnectorId, + isPreconfigured: true, + config: { + index: AlertHistoryDefaultIndexName, + }, + secrets: {}, + }); +} diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts new file mode 100644 index 0000000000000..a7038d8dc62eb --- /dev/null +++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'src/core/server'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { + createAlertHistoryIndexTemplate, + getAlertHistoryIndexTemplate, +} from './create_alert_history_index_template'; + +type MockedLogger = ReturnType; + +describe('createAlertHistoryIndexTemplate', () => { + let logger: MockedLogger; + let clusterClient: DeeplyMockedKeys; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + }); + + test(`should create index template if it doesn't exist`, async () => { + // Response type for existsIndexTemplate is still TODO + clusterClient.indices.existsIndexTemplate.mockResolvedValue({ + body: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await createAlertHistoryIndexTemplate({ client: clusterClient, logger }); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith({ + name: `kibana-alert-history-template`, + body: getAlertHistoryIndexTemplate(), + create: true, + }); + }); + + test(`shouldn't create index template if it already exists`, async () => { + // Response type for existsIndexTemplate is still TODO + clusterClient.indices.existsIndexTemplate.mockResolvedValue({ + body: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await createAlertHistoryIndexTemplate({ client: clusterClient, logger }); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts new file mode 100644 index 0000000000000..fe9874fb1d671 --- /dev/null +++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, Logger } from 'src/core/server'; +import { ALERT_HISTORY_PREFIX } from '../../../common'; +import mappings from './mappings.json'; + +export function getAlertHistoryIndexTemplate() { + return { + index_patterns: [`${ALERT_HISTORY_PREFIX}*`], + _meta: { + description: + 'System generated mapping for preconfigured alert history Elasticsearch index connector.', + }, + template: { + settings: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + mappings, + }, + }; +} + +async function doesIndexTemplateExist({ + client, + templateName, +}: { + client: ElasticsearchClient; + templateName: string; +}) { + let result; + try { + result = (await client.indices.existsIndexTemplate({ name: templateName })).body; + } catch (err) { + throw new Error(`error checking existence of index template: ${err.message}`); + } + + return result; +} + +async function createIndexTemplate({ + client, + template, + templateName, +}: { + client: ElasticsearchClient; + template: Record; + templateName: string; +}) { + try { + await client.indices.putIndexTemplate({ + name: templateName, + body: template, + create: true, + }); + } catch (err) { + // The error message doesn't have a type attribute we can look to guarantee it's due + // to the template already existing (only long message) so we'll check ourselves to see + // if the template now exists. This scenario would happen if you startup multiple Kibana + // instances at the same time. + const existsNow = await doesIndexTemplateExist({ client, templateName }); + if (!existsNow) { + throw new Error(`error creating index template: ${err.message}`); + } + } +} + +async function createIndexTemplateIfNotExists({ + client, + template, + templateName, +}: { + client: ElasticsearchClient; + template: Record; + templateName: string; +}) { + const indexTemplateExists = await doesIndexTemplateExist({ client, templateName }); + + if (!indexTemplateExists) { + await createIndexTemplate({ client, template, templateName }); + } +} + +export async function createAlertHistoryIndexTemplate({ + client, + logger, +}: { + client: ElasticsearchClient; + logger: Logger; +}) { + try { + const indexTemplate = getAlertHistoryIndexTemplate(); + await createIndexTemplateIfNotExists({ + client, + templateName: `${ALERT_HISTORY_PREFIX}template`, + template: indexTemplate, + }); + } catch (err) { + logger.error(`Could not initialize alert history index with mappings: ${err.message}.`); + } +} diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json new file mode 100644 index 0000000000000..56047f30d9489 --- /dev/null +++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json @@ -0,0 +1,84 @@ +{ + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "kibana": { + "properties": { + "alert": { + "properties": { + "actionGroup": { + "type": "keyword" + }, + "actionGroupName": { + "type": "keyword" + }, + "actionSubgroup": { + "type": "keyword" + }, + "context": { + "type": "object", + "enabled": false + }, + "id": { + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword", + "meta": { + "isArray": "true" + } + }, + "message": { + "norms": false, + "type": "text" + }, + "event": { + "properties": { + "kind": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "params": { + "type": "object", + "enabled": false + }, + "space": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index b7a6750a520ea..d6f99a766ed34 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -107,7 +107,11 @@ export interface ActionType< config?: ValidatorType; secrets?: ValidatorType; }; - renderParameterTemplates?(params: Params, variables: Record): Params; + renderParameterTemplates?( + params: Params, + variables: Record, + actionId?: string + ): Params; executor: ExecutorType; } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index 9999ea6a4d3d7..2ecf540485695 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -117,6 +117,7 @@ export function createExecutionHandler< params: transformActionParams({ actionsPlugin, alertId, + alertType: alertType.id, actionTypeId: action.actionTypeId, alertName, spaceId, @@ -127,6 +128,7 @@ export function createExecutionHandler< alertActionSubgroup: actionSubgroup, context, actionParams: action.params, + actionId: action.id, state, kibanaBaseUrl, alertParams, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index a3a7e9bbd9da5..50d710f6d6b14 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -153,7 +153,7 @@ describe('Task Runner', () => { actionsClient ); taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( - (actionTypeId, params) => params + (actionTypeId, actionId, params) => params ); }); diff --git a/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts index 6379192e855d7..e325d597da145 100644 --- a/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts @@ -34,6 +34,8 @@ test('skips non string parameters', () => { context: {}, state: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -68,6 +70,8 @@ test('missing parameters get emptied out', () => { context: {}, state: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -95,6 +99,8 @@ test('context parameters are passed to templates', () => { state: {}, context: { foo: 'fooVal' }, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -121,6 +127,8 @@ test('state parameters are passed to templates', () => { state: { bar: 'barVal' }, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -147,6 +155,8 @@ test('alertId is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -173,6 +183,8 @@ test('alertName is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -199,6 +211,8 @@ test('tags is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -225,6 +239,8 @@ test('undefined tags is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', spaceId: 'spaceId-A', alertInstanceId: '2', @@ -250,6 +266,8 @@ test('empty tags is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: [], spaceId: 'spaceId-A', @@ -276,6 +294,8 @@ test('spaceId is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -302,6 +322,8 @@ test('alertInstanceId is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -328,6 +350,8 @@ test('alertActionGroup is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -354,6 +378,8 @@ test('alertActionGroupName is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -380,6 +406,8 @@ test('rule variables are passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -408,6 +436,8 @@ test('rule alert variables are passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -436,6 +466,8 @@ test('date is passed to templates', () => { state: {}, context: {}, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -464,6 +496,8 @@ test('works recursively', () => { state: { value: 'state' }, context: { value: 'context' }, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', @@ -494,6 +528,8 @@ test('works recursively with arrays', () => { state: { value: 'state' }, context: { value: 'context' }, alertId: '1', + alertType: 'rule-type-id', + actionId: 'action-id', alertName: 'alert-name', tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', diff --git a/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts index 348bf01ea874b..3f9fe9e9c59e0 100644 --- a/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts @@ -16,6 +16,8 @@ import { PluginStartContract as ActionsPluginStartContract } from '../../../acti interface TransformActionParamsOptions { actionsPlugin: ActionsPluginStartContract; alertId: string; + alertType: string; + actionId: string; actionTypeId: string; alertName: string; spaceId: string; @@ -34,6 +36,8 @@ interface TransformActionParamsOptions { export function transformActionParams({ actionsPlugin, alertId, + alertType, + actionId, actionTypeId, alertName, spaceId, @@ -68,6 +72,7 @@ export function transformActionParams({ rule: { id: alertId, name: alertName, + type: alertType, spaceId, tags, }, @@ -78,5 +83,10 @@ export function transformActionParams({ actionSubgroup: alertActionSubgroup, }, }; - return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables); + return actionsPlugin.renderActionParameterTemplates( + actionTypeId, + actionId, + actionParams, + variables + ); } diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index e091b53b2e5b8..c80541ee1ba6b 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -22,11 +22,13 @@ const environmentLabels: Record = { }; export const ENVIRONMENT_ALL = { + esFieldValue: undefined, value: ENVIRONMENT_ALL_VALUE, text: environmentLabels[ENVIRONMENT_ALL_VALUE], }; export const ENVIRONMENT_NOT_DEFINED = { + esFieldValue: undefined, value: ENVIRONMENT_NOT_DEFINED_VALUE, text: environmentLabels[ENVIRONMENT_NOT_DEFINED_VALUE], }; @@ -35,6 +37,22 @@ export function getEnvironmentLabel(environment: string) { return environmentLabels[environment] || environment; } +export function parseEnvironmentUrlParam(environment: string) { + if (environment === ENVIRONMENT_ALL_VALUE) { + return ENVIRONMENT_ALL; + } + + if (environment === ENVIRONMENT_NOT_DEFINED_VALUE) { + return ENVIRONMENT_NOT_DEFINED; + } + + return { + esFieldValue: environment, + value: environment, + text: environment, + }; +} + // returns the environment url param that should be used // based on the requested environment. If the requested // environment is different from the URL parameter, we'll diff --git a/x-pack/plugins/apm/common/latency_aggregation_types.ts b/x-pack/plugins/apm/common/latency_aggregation_types.ts index d9db58f223144..964d6f4ed1015 100644 --- a/x-pack/plugins/apm/common/latency_aggregation_types.ts +++ b/x-pack/plugins/apm/common/latency_aggregation_types.ts @@ -14,7 +14,7 @@ export enum LatencyAggregationType { } export const latencyAggregationTypeRt = t.union([ - t.literal('avg'), - t.literal('p95'), - t.literal('p99'), + t.literal(LatencyAggregationType.avg), + t.literal(LatencyAggregationType.p95), + t.literal(LatencyAggregationType.p99), ]); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 1a17f82a52141..970e39bc4f86f 100644 --- a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -21,8 +21,5 @@ export const isoToEpochRt = new t.Type( ? t.failure(input, context) : t.success(epochDate); }), - (a) => { - const d = new Date(a); - return d.toISOString(); - } + (output) => new Date(output).toISOString() ); diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index e340f8bf19126..28e4a7b36e740 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -9,7 +9,8 @@ "licensing", "triggersActionsUi", "embeddable", - "infra" + "infra", + "observability" ], "optionalPlugins": [ "spaces", @@ -18,7 +19,6 @@ "taskManager", "actions", "alerting", - "observability", "security", "ml", "home", diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx index 6a6db40892e10..407f460f25ad3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx @@ -14,15 +14,18 @@ import { act, waitFor, } from '@testing-library/react'; -import * as apmApi from '../../../../../../services/rest/createCallApmApi'; +import { + getCallApmApiSpy, + CallApmApiSpy, +} from '../../../../../../services/rest/callApmApiSpy'; export const removeExternalLinkText = (str: string) => str.replace(/\(opens in a new tab or window\)/g, ''); describe('LinkPreview', () => { - let callApmApiSpy: jest.SpyInstance; + let callApmApiSpy: CallApmApiSpy; beforeAll(() => { - callApmApiSpy = jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({ + callApmApiSpy = getCallApmApiSpy().mockResolvedValue({ transaction: { id: 'foo' }, }); }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 77835afef863a..7d119b8c406da 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -8,6 +8,7 @@ import { fireEvent, render, RenderResult } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; +import { getCallApmApiSpy } from '../../../../../services/rest/callApmApiSpy'; import { CustomLinkOverview } from '.'; import { License } from '../../../../../../../licensing/common/license'; import { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; @@ -17,7 +18,6 @@ import { } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../../../context/license/license_context'; import * as hooks from '../../../../../hooks/use_fetcher'; -import * as apmApi from '../../../../../services/rest/createCallApmApi'; import { expectTextsInDocument, expectTextsNotInDocument, @@ -43,7 +43,7 @@ function getMockAPMContext({ canSave }: { canSave: boolean }) { describe('CustomLink', () => { beforeAll(() => { - jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({}); + getCallApmApiSpy().mockResolvedValue({}); }); afterAll(() => { jest.resetAllMocks(); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index b30faac7a65af..c6ed4e640693f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -22,9 +22,12 @@ import * as useTransactionBreakdownHooks from '../../shared/charts/transaction_b import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; import { waitFor } from '@testing-library/dom'; -import * as callApmApiModule from '../../../services/rest/createCallApmApi'; import * as useApmServiceContextHooks from '../../../context/apm_service/use_apm_service_context'; import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { + getCallApmApiSpy, + getCreateCallApmApiSpy, +} from '../../../services/rest/callApmApiSpy'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -83,10 +86,10 @@ describe('ServiceOverview', () => { /* eslint-disable @typescript-eslint/naming-convention */ const calls = { 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics': { - error_groups: [], + error_groups: [] as any[], }, 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { - transactionGroups: [], + transactionGroups: [] as any[], totalTransactionGroups: 0, isAggregationAccurate: true, }, @@ -95,19 +98,17 @@ describe('ServiceOverview', () => { }; /* eslint-enable @typescript-eslint/naming-convention */ - jest - .spyOn(callApmApiModule, 'createCallApmApi') - .mockImplementation(() => {}); - - const callApmApi = jest - .spyOn(callApmApiModule, 'callApmApi') - .mockImplementation(({ endpoint }) => { + const callApmApiSpy = getCallApmApiSpy().mockImplementation( + ({ endpoint }) => { const response = calls[endpoint as keyof typeof calls]; return response ? Promise.resolve(response) : Promise.reject(`Response for ${endpoint} is not defined`); - }); + } + ); + + getCreateCallApmApiSpy().mockImplementation(() => callApmApiSpy as any); jest .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') .mockReturnValue({ @@ -124,7 +125,7 @@ describe('ServiceOverview', () => { ); await waitFor(() => - expect(callApmApi).toHaveBeenCalledTimes(Object.keys(calls).length) + expect(callApmApiSpy).toHaveBeenCalledTimes(Object.keys(calls).length) ); expect((await findAllByText('Latency')).length).toBeGreaterThan(0); diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts index 29fabc51fd582..00447607cf787 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts @@ -10,10 +10,10 @@ import { fetchObservabilityOverviewPageData, getHasData, } from './apm_observability_overview_fetchers'; -import * as createCallApmApi from './createCallApmApi'; +import { getCallApmApiSpy } from './callApmApiSpy'; describe('Observability dashboard data', () => { - const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const callApmApiMock = getCallApmApiSpy(); const params = { absoluteTime: { start: moment('2020-07-02T13:25:11.629Z').valueOf(), @@ -84,7 +84,7 @@ describe('Observability dashboard data', () => { callApmApiMock.mockImplementation(() => Promise.resolve({ serviceCount: 0, - transactionPerMinute: { value: null, timeseries: [] }, + transactionPerMinute: { value: null, timeseries: [] as any }, }) ); const response = await fetchObservabilityOverviewPageData(params); diff --git a/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts new file mode 100644 index 0000000000000..ba9f740e06d0d --- /dev/null +++ b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as createCallApmApi from './createCallApmApi'; +import type { AbstractAPMClient } from './createCallApmApi'; + +export type CallApmApiSpy = jest.SpyInstance< + Promise, + Parameters +>; + +export type CreateCallApmApiSpy = jest.SpyInstance; + +export const getCreateCallApmApiSpy = () => + (jest.spyOn( + createCallApmApi, + 'createCallApmApi' + ) as unknown) as CreateCallApmApiSpy; +export const getCallApmApiSpy = () => + (jest.spyOn(createCallApmApi, 'callApmApi') as unknown) as CallApmApiSpy; diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index b0cce3296fe21..0e82d70faf1e1 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -6,30 +6,68 @@ */ import { CoreSetup, CoreStart } from 'kibana/public'; -import { parseEndpoint } from '../../../common/apm_api/parse_endpoint'; +import * as t from 'io-ts'; +import type { + ClientRequestParamsOf, + EndpointOf, + ReturnOf, + RouteRepositoryClient, + ServerRouteRepository, + ServerRoute, +} from '@kbn/server-route-repository'; +import { formatRequest } from '@kbn/server-route-repository/target/format_request'; import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { APMAPI } from '../../../server/routes/create_apm_api'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { Client } from '../../../server/routes/typings'; - -export type APMClient = Client; -export type AutoAbortedAPMClient = Client; +import type { + APMServerRouteRepository, + InspectResponse, + APMRouteHandlerResources, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server'; export type APMClientOptions = Omit< FetchOptions, 'query' | 'body' | 'pathname' | 'signal' > & { - endpoint: string; signal: AbortSignal | null; - params?: { - body?: any; - query?: Record; - path?: Record; - }; }; +export type APMClient = RouteRepositoryClient< + APMServerRouteRepository, + APMClientOptions +>; + +export type AutoAbortedAPMClient = RouteRepositoryClient< + APMServerRouteRepository, + Omit +>; + +export type APIReturnType< + TEndpoint extends EndpointOf +> = ReturnOf & { + _inspect?: InspectResponse; +}; + +export type APIEndpoint = EndpointOf; + +export type APIClientRequestParamsOf< + TEndpoint extends EndpointOf +> = ClientRequestParamsOf; + +export type AbstractAPMRepository = ServerRouteRepository< + APMRouteHandlerResources, + {}, + Record< + string, + ServerRoute + > +>; + +export type AbstractAPMClient = RouteRepositoryClient< + AbstractAPMRepository, + APMClientOptions +>; + export let callApmApi: APMClient = () => { throw new Error( 'callApmApi has to be initialized before used. Call createCallApmApi first.' @@ -37,9 +75,13 @@ export let callApmApi: APMClient = () => { }; export function createCallApmApi(core: CoreStart | CoreSetup) { - callApmApi = ((options: APMClientOptions) => { - const { endpoint, params, ...opts } = options; - const { method, pathname } = parseEndpoint(endpoint, params?.path); + callApmApi = ((options) => { + const { endpoint, ...opts } = options; + const { params } = (options as unknown) as { + params?: Partial>; + }; + + const { method, pathname } = formatRequest(endpoint, params?.path); return callApi(core, { ...opts, @@ -50,10 +92,3 @@ export function createCallApmApi(core: CoreStart | CoreSetup) { }); }) as APMClient; } - -// infer return type from API -export type APIReturnType< - TPath extends keyof APMAPI['_S'] -> = APMAPI['_S'][TPath] extends { ret: any } - ? APMAPI['_S'][TPath]['ret'] - : unknown; diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 319eb53313231..40d42298b967b 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -2,6 +2,7 @@ "include": [ "./x-pack/plugins/apm/**/*", "./x-pack/plugins/observability/**/*", + "./x-pack/plugins/rule_registry/**/*", "./typings/**/*" ], "exclude": [ diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js index 695a9ba70f5d7..88d2e169dd542 100644 --- a/x-pack/plugins/apm/scripts/precommit.js +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -28,19 +28,8 @@ const testTsconfig = resolve(root, 'x-pack/test/tsconfig.json'); const tasks = new Listr( [ { - title: 'Jest', - task: () => - execa( - 'node', - [ - resolve(__dirname, './jest.js'), - '--reporters', - resolve(__dirname, '../../../../node_modules/jest-silent-reporter'), - '--collect-coverage', - 'false', - ], - execaOpts - ), + title: 'Lint', + task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts), }, { title: 'Typescript', @@ -72,11 +61,22 @@ const tasks = new Listr( ), }, { - title: 'Lint', - task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts), + title: 'Jest', + task: () => + execa( + 'node', + [ + resolve(__dirname, './jest.js'), + '--reporters', + resolve(__dirname, '../../../../node_modules/jest-silent-reporter'), + '--collect-coverage', + 'false', + ], + execaOpts + ), }, ], - { exitOnError: true, concurrent: true } + { exitOnError: true, concurrent: false } ); tasks.run().catch((error) => { diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 00910353ac278..9ab56c1a303ea 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -120,5 +120,9 @@ export function mergeConfigs( export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); -export { APMPlugin, APMPluginSetup } from './plugin'; +export { APMPlugin } from './plugin'; +export { APMPluginSetup } from './types'; +export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository'; +export { InspectResponse, APMRouteHandlerResources } from './routes/typings'; + export type { ProcessorEvent } from '../common/processor_event'; diff --git a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts index 473912c4177a9..b065da7123dec 100644 --- a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts +++ b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts @@ -13,28 +13,28 @@ export const apmActionVariables = { 'xpack.apm.alerts.action_variables.serviceName', { defaultMessage: 'The service the alert is created for' } ), - name: 'serviceName', + name: 'serviceName' as const, }, transactionType: { description: i18n.translate( 'xpack.apm.alerts.action_variables.transactionType', { defaultMessage: 'The transaction type the alert is created for' } ), - name: 'transactionType', + name: 'transactionType' as const, }, environment: { description: i18n.translate( 'xpack.apm.alerts.action_variables.environment', { defaultMessage: 'The transaction type the alert is created for' } ), - name: 'environment', + name: 'environment' as const, }, threshold: { description: i18n.translate('xpack.apm.alerts.action_variables.threshold', { defaultMessage: 'Any trigger value above this value will cause the alert to fire', }), - name: 'threshold', + name: 'threshold' as const, }, triggerValue: { description: i18n.translate( @@ -44,7 +44,7 @@ export const apmActionVariables = { 'The value that breached the threshold and triggered the alert', } ), - name: 'triggerValue', + name: 'triggerValue' as const, }, interval: { description: i18n.translate( @@ -54,6 +54,6 @@ export const apmActionVariables = { 'The length and unit of the time period where the alert conditions were met', } ), - name: 'interval', + name: 'interval' as const, }, }; diff --git a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts index 9a0ba514bb479..e3d5e5481caa5 100644 --- a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts +++ b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts @@ -5,28 +5,24 @@ * 2.0. */ -import { ApiResponse } from '@elastic/elasticsearch'; -import { ThresholdMetActionGroupId } from '../../../common/alert_types'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../../typings/elasticsearch'; -import { - AlertInstanceContext, - AlertInstanceState, - AlertServices, -} from '../../../../alerting/server'; +import { AlertServices } from '../../../../alerting/server'; -export function alertingEsClient( - services: AlertServices< - AlertInstanceState, - AlertInstanceContext, - ThresholdMetActionGroupId - >, +export async function alertingEsClient( + scopedClusterClient: AlertServices< + never, + never, + never + >['scopedClusterClient'], params: TParams -): Promise>> { - return (services.scopedClusterClient.asCurrentUser.search({ +): Promise> { + const response = await scopedClusterClient.asCurrentUser.search({ ...params, ignore_unavailable: true, - }) as unknown) as Promise>>; + }); + + return (response.body as unknown) as ESSearchResponse; } diff --git a/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts b/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts new file mode 100644 index 0000000000000..8d250a5765cce --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; +import { APMRuleRegistry } from '../../plugin'; + +export const createAPMLifecycleRuleType = createLifecycleRuleTypeFactory(); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts index a9824c130faa5..9a362efa90ac0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -6,38 +6,25 @@ */ import { Observable } from 'rxjs'; -import { AlertingPlugin } from '../../../../alerting/server'; -import { ActionsPlugin } from '../../../../actions/server'; +import { Logger } from 'kibana/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; +import { APMRuleRegistry } from '../../plugin'; -interface Params { - alerting: AlertingPlugin['setup']; - actions: ActionsPlugin['setup']; +export interface RegisterRuleDependencies { + registry: APMRuleRegistry; ml?: MlPluginSetup; config$: Observable; + logger: Logger; } -export function registerApmAlerts(params: Params) { - registerTransactionDurationAlertType({ - alerting: params.alerting, - config$: params.config$, - }); - registerTransactionDurationAnomalyAlertType({ - alerting: params.alerting, - ml: params.ml, - config$: params.config$, - }); - registerErrorCountAlertType({ - alerting: params.alerting, - config$: params.config$, - }); - registerTransactionErrorRateAlertType({ - alerting: params.alerting, - config$: params.config$, - }); +export function registerApmAlerts(dependencies: RegisterRuleDependencies) { + registerTransactionDurationAlertType(dependencies); + registerTransactionDurationAnomalyAlertType(dependencies); + registerErrorCountAlertType(dependencies); + registerTransactionErrorRateAlertType(dependencies); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts index d7dd7aee3ca25..5758dea1860b2 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts @@ -5,50 +5,17 @@ * 2.0. */ -import { Observable } from 'rxjs'; -import * as Rx from 'rxjs'; -import { toArray, map } from 'rxjs/operators'; - -import { AlertingPlugin } from '../../../../alerting/server'; -import { APMConfig } from '../..'; - import { registerErrorCountAlertType } from './register_error_count_alert_type'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; - -type Operator = (source: Rx.Observable) => Rx.Observable; -const pipeClosure = (fn: Operator): Operator => { - return (source: Rx.Observable) => { - return Rx.defer(() => fn(source)); - }; -}; -const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( - pipeClosure((source$) => { - return source$.pipe(map((i) => i)); - }), - toArray() -) as unknown) as Observable; +import { createRuleTypeMocks } from './test_utils'; describe('Error count alert', () => { it("doesn't send an alert when error count is less than threshold", async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { services, dependencies, executor } = createRuleTypeMocks(); - registerErrorCountAlertType({ - alerting, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); + registerErrorCountAlertType(dependencies); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(), - }; const params = { threshold: 1 }; services.scopedClusterClient.asCurrentUser.search.mockReturnValue( @@ -71,30 +38,21 @@ describe('Error count alert', () => { }) ); - await alertExecutor!({ services, params }); + await executor({ params }); expect(services.alertInstanceFactory).not.toBeCalled(); }); - it('sends alerts with service name and environment', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + it('sends alerts with service name and environment for those that exceeded the threshold', async () => { + const { + services, + dependencies, + executor, + scheduleActions, + } = createRuleTypeMocks(); - registerErrorCountAlertType({ - alerting, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); + registerErrorCountAlertType(dependencies); - const scheduleActions = jest.fn(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; + const params = { threshold: 2, windowSize: 5, windowUnit: 'm' }; services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -106,18 +64,62 @@ describe('Error count alert', () => { }, }, aggregations: { - services: { + error_counts: { buckets: [ { - key: 'foo', - environments: { - buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }], + key: ['foo', 'env-foo'], + doc_count: 5, + latest: { + top: [ + { + metrics: { + 'service.name': 'foo', + 'service.environment': 'env-foo', + }, + }, + ], + }, + }, + { + key: ['foo', 'env-foo-2'], + doc_count: 4, + latest: { + top: [ + { + metrics: { + 'service.name': 'foo', + 'service.environment': 'env-foo-2', + }, + }, + ], }, }, { - key: 'bar', - environments: { - buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }], + key: ['bar', 'env-bar'], + doc_count: 3, + latest: { + top: [ + { + metrics: { + 'service.name': 'bar', + 'service.environment': 'env-bar', + }, + }, + ], + }, + }, + { + key: ['bar', 'env-bar-2'], + doc_count: 1, + latest: { + top: [ + { + metrics: { + 'service.name': 'bar', + 'service.environment': 'env-bar-2', + }, + }, + ], }, }, ], @@ -134,115 +136,36 @@ describe('Error count alert', () => { }) ); - await alertExecutor!({ services, params }); + await executor({ params }); [ 'apm.error_rate_foo_env-foo', 'apm.error_rate_foo_env-foo-2', 'apm.error_rate_bar_env-bar', - 'apm.error_rate_bar_env-bar-2', ].forEach((instanceName) => expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) ); + expect(scheduleActions).toHaveBeenCalledTimes(3); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', environment: 'env-foo', - threshold: 1, - triggerValue: 2, + threshold: 2, + triggerValue: 5, interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', environment: 'env-foo-2', - threshold: 1, - triggerValue: 2, + threshold: 2, + triggerValue: 4, interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: 'env-bar', - threshold: 1, - triggerValue: 2, - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - environment: 'env-bar-2', - threshold: 1, - triggerValue: 2, - interval: '5m', - }); - }); - it('sends alerts with service name', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; - - registerErrorCountAlertType({ - alerting, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); - - const scheduleActions = jest.fn(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; - - services.scopedClusterClient.asCurrentUser.search.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - hits: { - hits: [], - total: { - relation: 'eq', - value: 2, - }, - }, - aggregations: { - services: { - buckets: [ - { - key: 'foo', - }, - { - key: 'bar', - }, - ], - }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - skipped: 0, - successful: 1, - total: 1, - }, - }) - ); - - await alertExecutor!({ services, params }); - ['apm.error_rate_foo', 'apm.error_rate_bar'].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) - ); - - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - environment: undefined, - threshold: 1, - triggerValue: 2, - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - environment: undefined, - threshold: 1, - triggerValue: 2, + threshold: 2, + triggerValue: 3, interval: '5m', }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 0120891a8f868..8240e0c369d1f 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -5,22 +5,11 @@ * 2.0. */ -import { schema, TypeOf } from '@kbn/config-schema'; -import { isEmpty } from 'lodash'; -import { Observable } from 'rxjs'; +import { schema } from '@kbn/config-schema'; import { take } from 'rxjs/operators'; -import { APMConfig } from '../..'; -import { - AlertingPlugin, - AlertInstanceContext, - AlertInstanceState, - AlertTypeState, -} from '../../../../alerting/server'; -import { - AlertType, - ALERT_TYPES_CONFIG, - ThresholdMetActionGroupId, -} from '../../../common/alert_types'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { PROCESSOR_EVENT, SERVICE_ENVIRONMENT, @@ -31,11 +20,8 @@ import { environmentQuery } from '../../../server/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; - -interface RegisterAlertParams { - alerting: AlertingPlugin['setup']; - config$: Observable; -} +import { RegisterRuleDependencies } from './register_apm_alerts'; +import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type'; const paramsSchema = schema.object({ windowSize: schema.number(), @@ -48,127 +34,131 @@ const paramsSchema = schema.object({ const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorCount]; export function registerErrorCountAlertType({ - alerting, + registry, config$, -}: RegisterAlertParams) { - alerting.registerType< - TypeOf, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - ThresholdMetActionGroupId - >({ - id: AlertType.ErrorCount, - name: alertTypeConfig.name, - actionGroups: alertTypeConfig.actionGroups, - defaultActionGroupId: alertTypeConfig.defaultActionGroupId, - validate: { - params: paramsSchema, - }, - actionVariables: { - context: [ - apmActionVariables.serviceName, - apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, - apmActionVariables.interval, - ], - }, - producer: 'apm', - minimumLicenseRequired: 'basic', - executor: async ({ services, params }) => { - const config = await config$.pipe(take(1)).toPromise(); - const alertParams = params; - const indices = await getApmIndices({ - config, - savedObjectsClient: services.savedObjectsClient, - }); - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; +}: RegisterRuleDependencies) { + registry.registerType( + createAPMLifecycleRuleType({ + id: AlertType.ErrorCount, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + apmActionVariables.interval, + ], + }, + producer: 'apm', + minimumLicenseRequired: 'basic', + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + const alertParams = params; + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient, + }); - const searchParams = { - index: indices['apm_oss.errorIndices'], - size: 0, - body: { - track_total_hits: true, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + const searchParams = { + index: indices['apm_oss.errorIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + }, }, }, - }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, - ...(alertParams.serviceName - ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] - : []), - ...environmentQuery(alertParams.environment), - ], - }, - }, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - size: 50, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + ...(alertParams.serviceName + ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] + : []), + ...environmentQuery(alertParams.environment), + ], }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - size: maxServiceEnvironments, + }, + aggs: { + error_counts: { + multi_terms: { + terms: [ + { field: SERVICE_NAME }, + { field: SERVICE_ENVIRONMENT, missing: '' }, + ], + size: 10000, + }, + aggs: { + latest: { + top_metrics: { + metrics: asMutableArray([ + { field: SERVICE_NAME }, + { field: SERVICE_ENVIRONMENT }, + ] as const), + sort: { + '@timestamp': 'desc' as const, + }, + }, }, }, }, }, }, - }, - }; + }; + + const response = await alertingEsClient( + services.scopedClusterClient, + searchParams + ); - const { body: response } = await alertingEsClient(services, searchParams); - const errorCount = response.hits.total.value; + const errorCountResults = + response.aggregations?.error_counts.buckets.map((bucket) => { + const latest = bucket.latest.top[0].metrics; - if (errorCount > alertParams.threshold) { - function scheduleAction({ - serviceName, - environment, - }: { - serviceName: string; - environment?: string; - }) { - const alertInstanceName = [ - AlertType.ErrorCount, - serviceName, - environment, - ] - .filter((name) => name) - .join('_'); + return { + serviceName: latest['service.name'] as string, + environment: latest['service.environment'] as string | undefined, + errorCount: bucket.doc_count, + }; + }) ?? []; - const alertInstance = services.alertInstanceFactory( - alertInstanceName - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - serviceName, - environment, - threshold: alertParams.threshold, - triggerValue: errorCount, - interval: `${alertParams.windowSize}${alertParams.windowUnit}`, + errorCountResults + .filter((result) => result.errorCount >= alertParams.threshold) + .forEach((result) => { + const { serviceName, environment, errorCount } = result; + + services + .alertWithLifecycle({ + id: [AlertType.ErrorCount, serviceName, environment] + .filter((name) => name) + .join('_'), + fields: { + [SERVICE_NAME]: serviceName, + ...(environment + ? { [SERVICE_ENVIRONMENT]: environment } + : {}), + [PROCESSOR_EVENT]: 'error', + }, + }) + .scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + environment: environment || ENVIRONMENT_NOT_DEFINED.text, + threshold: alertParams.threshold, + triggerValue: errorCount, + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, + }); }); - } - response.aggregations?.services.buckets.forEach((serviceBucket) => { - const serviceName = serviceBucket.key as string; - if (isEmpty(serviceBucket.environments?.buckets)) { - scheduleAction({ serviceName }); - } else { - serviceBucket.environments.buckets.forEach((envBucket) => { - const environment = envBucket.key as string; - scheduleAction({ serviceName, environment }); - }); - } - }); - } - }, - }); + + return {}; + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 500e0744d5638..6ca1c4370d6ae 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -6,10 +6,9 @@ */ import { schema } from '@kbn/config-schema'; -import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { APMConfig } from '../..'; -import { AlertingPlugin } from '../../../../alerting/server'; +import { QueryContainer } from '@elastic/elasticsearch/api/types'; +import { parseEnvironmentUrlParam } from '../../../common/environment_filter_values'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { PROCESSOR_EVENT, @@ -24,11 +23,8 @@ import { environmentQuery } from '../../../server/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; - -interface RegisterAlertParams { - alerting: AlertingPlugin['setup']; - config$: Observable; -} +import { RegisterRuleDependencies } from './register_apm_alerts'; +import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type'; const paramsSchema = schema.object({ serviceName: schema.string(), @@ -47,116 +43,126 @@ const paramsSchema = schema.object({ const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDuration]; export function registerTransactionDurationAlertType({ - alerting, + registry, config$, -}: RegisterAlertParams) { - alerting.registerType({ - id: AlertType.TransactionDuration, - name: alertTypeConfig.name, - actionGroups: alertTypeConfig.actionGroups, - defaultActionGroupId: alertTypeConfig.defaultActionGroupId, - validate: { - params: paramsSchema, - }, - actionVariables: { - context: [ - apmActionVariables.serviceName, - apmActionVariables.transactionType, - apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, - apmActionVariables.interval, - ], - }, - producer: 'apm', - minimumLicenseRequired: 'basic', - executor: async ({ services, params }) => { - const config = await config$.pipe(take(1)).toPromise(); - const alertParams = params; - const indices = await getApmIndices({ - config, - savedObjectsClient: services.savedObjectsClient, - }); - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; +}: RegisterRuleDependencies) { + registry.registerType( + createAPMLifecycleRuleType({ + id: AlertType.TransactionDuration, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + apmActionVariables.interval, + ], + }, + producer: 'apm', + minimumLicenseRequired: 'basic', + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + const alertParams = params; + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient, + }); - const searchParams = { - index: indices['apm_oss.transactionIndices'], - size: 0, - body: { - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + }, }, }, - }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - { term: { [SERVICE_NAME]: alertParams.serviceName } }, - { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, - ...environmentQuery(alertParams.environment), - ], + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...environmentQuery(alertParams.environment), + ] as QueryContainer[], + }, }, - }, - aggs: { - agg: - alertParams.aggregationType === 'avg' - ? { avg: { field: TRANSACTION_DURATION } } - : { - percentiles: { - field: TRANSACTION_DURATION, - percents: [ - alertParams.aggregationType === '95th' ? 95 : 99, - ], + aggs: { + latency: + alertParams.aggregationType === 'avg' + ? { avg: { field: TRANSACTION_DURATION } } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [ + alertParams.aggregationType === '95th' ? 95 : 99, + ], + }, }, - }, - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - size: maxServiceEnvironments, - }, }, }, - }, - }; + }; - const { body: response } = await alertingEsClient(services, searchParams); + const response = await alertingEsClient( + services.scopedClusterClient, + searchParams + ); - if (!response.aggregations) { - return; - } + if (!response.aggregations) { + return {}; + } - const { agg, environments } = response.aggregations; + const { latency } = response.aggregations; - const transactionDuration = - 'values' in agg ? Object.values(agg.values)[0] : agg?.value; + const transactionDuration = + 'values' in latency + ? Object.values(latency.values)[0] + : latency?.value; - const threshold = alertParams.threshold * 1000; + const threshold = alertParams.threshold * 1000; - if (transactionDuration && transactionDuration > threshold) { - const durationFormatter = getDurationFormatter(transactionDuration); - const transactionDurationFormatted = durationFormatter( - transactionDuration - ).formatted; + if (transactionDuration && transactionDuration > threshold) { + const durationFormatter = getDurationFormatter(transactionDuration); + const transactionDurationFormatted = durationFormatter( + transactionDuration + ).formatted; - environments.buckets.map((bucket) => { - const environment = bucket.key; - const alertInstance = services.alertInstanceFactory( - `${AlertType.TransactionDuration}_${environment}` + const environmentParsed = parseEnvironmentUrlParam( + alertParams.environment ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - transactionType: alertParams.transactionType, - serviceName: alertParams.serviceName, - environment, - threshold, - triggerValue: transactionDurationFormatted, - interval: `${alertParams.windowSize}${alertParams.windowUnit}`, - }); - }); - } - }, - }); + services + .alertWithLifecycle({ + id: `${AlertType.TransactionDuration}_${environmentParsed.text}`, + fields: { + [SERVICE_NAME]: alertParams.serviceName, + ...(environmentParsed.esFieldValue + ? { [SERVICE_ENVIRONMENT]: environmentParsed.esFieldValue } + : {}), + [TRANSACTION_TYPE]: alertParams.transactionType, + }, + }) + .scheduleActions(alertTypeConfig.defaultActionGroupId, { + transactionType: alertParams.transactionType, + serviceName: alertParams.serviceName, + environment: environmentParsed.text, + threshold, + triggerValue: transactionDurationFormatted, + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, + }); + } + + return {}; + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts index 5f6c07cae4b8f..b9346b2bf4649 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -4,29 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { Observable } from 'rxjs'; -import * as Rx from 'rxjs'; -import { toArray, map } from 'rxjs/operators'; -import { AlertingPlugin } from '../../../../alerting/server'; import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; -import { APMConfig } from '../..'; import { ANOMALY_SEVERITY } from '../../../../ml/common'; import { Job, MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; - -type Operator = (source: Rx.Observable) => Rx.Observable; -const pipeClosure = (fn: Operator): Operator => { - return (source: Rx.Observable) => { - return Rx.defer(() => fn(source)); - }; -}; -const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( - pipeClosure((source$) => { - return source$.pipe(map((i) => i)); - }), - toArray() -) as unknown) as Observable; +import { createRuleTypeMocks } from './test_utils'; describe('Transaction duration anomaly alert', () => { afterEach(() => { @@ -34,28 +16,21 @@ describe('Transaction duration anomaly alert', () => { }); describe("doesn't send alert", () => { it('ml is not defined', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { services, dependencies, executor } = createRuleTypeMocks(); registerTransactionDurationAnomalyAlertType({ - alerting, + ...dependencies, ml: undefined, - config$: mockedConfig$, }); - expect(alertExecutor).toBeDefined(); - const services = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; - await alertExecutor!({ services, params }); - expect(services.callCluster).not.toHaveBeenCalled(); + await executor({ params }); + + expect( + services.scopedClusterClient.asCurrentUser.search + ).not.toHaveBeenCalled(); + expect(services.alertInstanceFactory).not.toHaveBeenCalled(); }); @@ -64,13 +39,7 @@ describe('Transaction duration anomaly alert', () => { .spyOn(GetServiceAnomalies, 'getMLJobs') .mockReturnValue(Promise.resolve([])); - let alertExecutor: any; - - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { services, dependencies, executor } = createRuleTypeMocks(); const ml = ({ mlSystemProvider: () => ({ mlAnomalySearch: jest.fn() }), @@ -78,117 +47,47 @@ describe('Transaction duration anomaly alert', () => { } as unknown) as MlPluginSetup; registerTransactionDurationAnomalyAlertType({ - alerting, + ...dependencies, ml, - config$: mockedConfig$, }); - expect(alertExecutor).toBeDefined(); - const services = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; - await alertExecutor!({ services, params }); - expect(services.callCluster).not.toHaveBeenCalled(); - expect(services.alertInstanceFactory).not.toHaveBeenCalled(); - }); + await executor({ params }); + expect( + services.scopedClusterClient.asCurrentUser.search + ).not.toHaveBeenCalled(); - it('anomaly is less than threshold', async () => { - jest - .spyOn(GetServiceAnomalies, 'getMLJobs') - .mockReturnValue( - Promise.resolve([{ job_id: '1' }, { job_id: '2' }] as Job[]) - ); - - let alertExecutor: any; - - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; - - const ml = ({ - mlSystemProvider: () => ({ - mlAnomalySearch: () => ({ - hits: { total: { value: 0 } }, - }), - }), - anomalyDetectorsProvider: jest.fn(), - } as unknown) as MlPluginSetup; - - registerTransactionDurationAnomalyAlertType({ - alerting, - ml, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); - - const services = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; - const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; - - await alertExecutor!({ services, params }); - expect(services.callCluster).not.toHaveBeenCalled(); expect(services.alertInstanceFactory).not.toHaveBeenCalled(); }); - }); - describe('sends alert', () => { - it('with service name, environment and transaction type', async () => { + it('anomaly is less than threshold', async () => { jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( - Promise.resolve([ + Promise.resolve(([ { job_id: '1', - custom_settings: { - job_tags: { - environment: 'production', - }, - }, - } as unknown, + custom_settings: { job_tags: { environment: 'development' } }, + }, { job_id: '2', - custom_settings: { - job_tags: { - environment: 'production', - }, - }, - } as unknown, - ] as Job[]) + custom_settings: { job_tags: { environment: 'production' } }, + }, + ] as unknown) as Job[]) ); - let alertExecutor: any; - - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { services, dependencies, executor } = createRuleTypeMocks(); const ml = ({ mlSystemProvider: () => ({ mlAnomalySearch: () => ({ - hits: { total: { value: 2 } }, aggregations: { - services: { + anomaly_groups: { buckets: [ { - key: 'foo', - transaction_types: { - buckets: [{ key: 'type-foo' }], - }, - record_avg: { value: 80 }, - }, - { - key: 'bar', - transaction_types: { - buckets: [{ key: 'type-bar' }], + doc_count: 1, + latest_score: { + top: [{ metrics: { record_score: 0, job_id: '1' } }], }, - record_avg: { value: 20 }, }, ], }, @@ -199,84 +98,77 @@ describe('Transaction duration anomaly alert', () => { } as unknown) as MlPluginSetup; registerTransactionDurationAnomalyAlertType({ - alerting, + ...dependencies, ml, - config$: mockedConfig$, }); - expect(alertExecutor).toBeDefined(); - const scheduleActions = jest.fn(); - const services = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; - await alertExecutor!({ services, params }); - - await alertExecutor!({ services, params }); - [ - 'apm.transaction_duration_anomaly_foo_production_type-foo', - 'apm.transaction_duration_anomaly_bar_production_type-bar', - ].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) - ); + await executor({ params }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: 'type-foo', - environment: 'production', - threshold: 'minor', - thresholdValue: 'critical', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: 'type-bar', - environment: 'production', - threshold: 'minor', - thresholdValue: 'warning', - }); + expect( + services.scopedClusterClient.asCurrentUser.search + ).not.toHaveBeenCalled(); + expect(services.alertInstanceFactory).not.toHaveBeenCalled(); }); + }); - it('with service name', async () => { + describe('sends alert', () => { + it('for all services that exceeded the threshold', async () => { jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( - Promise.resolve([ + Promise.resolve(([ { job_id: '1', - custom_settings: { - job_tags: { - environment: 'production', - }, - }, - } as unknown, + custom_settings: { job_tags: { environment: 'development' } }, + }, { job_id: '2', - custom_settings: { - job_tags: { - environment: 'testing', - }, - }, - } as unknown, - ] as Job[]) + custom_settings: { job_tags: { environment: 'production' } }, + }, + ] as unknown) as Job[]) ); - let alertExecutor: any; - - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { + services, + dependencies, + executor, + scheduleActions, + } = createRuleTypeMocks(); const ml = ({ mlSystemProvider: () => ({ mlAnomalySearch: () => ({ - hits: { total: { value: 2 } }, aggregations: { - services: { + anomaly_groups: { buckets: [ - { key: 'foo', record_avg: { value: 80 } }, - { key: 'bar', record_avg: { value: 20 } }, + { + latest_score: { + top: [ + { + metrics: { + record_score: 80, + job_id: '1', + partition_field_value: 'foo', + by_field_value: 'type-foo', + }, + }, + ], + }, + }, + { + latest_score: { + top: [ + { + metrics: { + record_score: 20, + job_id: '2', + parttition_field_value: 'bar', + by_field_value: 'type-bar', + }, + }, + ], + }, + }, ], }, }, @@ -286,58 +178,26 @@ describe('Transaction duration anomaly alert', () => { } as unknown) as MlPluginSetup; registerTransactionDurationAnomalyAlertType({ - alerting, + ...dependencies, ml, - config$: mockedConfig$, }); - expect(alertExecutor).toBeDefined(); - const scheduleActions = jest.fn(); - const services = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; - await alertExecutor!({ services, params }); + await executor({ params }); + + expect(services.alertInstanceFactory).toHaveBeenCalledTimes(1); - await alertExecutor!({ services, params }); - [ - 'apm.transaction_duration_anomaly_foo_production', - 'apm.transaction_duration_anomaly_foo_testing', - 'apm.transaction_duration_anomaly_bar_production', - 'apm.transaction_duration_anomaly_bar_testing', - ].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + expect(services.alertInstanceFactory).toHaveBeenCalledWith( + 'apm.transaction_duration_anomaly_foo_development_type-foo' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', - transactionType: undefined, - environment: 'production', - threshold: 'minor', - thresholdValue: 'critical', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: undefined, - environment: 'production', - threshold: 'minor', - thresholdValue: 'warning', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: undefined, - environment: 'testing', - threshold: 'minor', - thresholdValue: 'critical', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: undefined, - environment: 'testing', + transactionType: 'type-foo', + environment: 'development', threshold: 'minor', - thresholdValue: 'warning', + triggerValue: 'critical', }); }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 84c3ec7325fd2..15f4a8ea07801 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -6,9 +6,16 @@ */ import { schema } from '@kbn/config-schema'; -import { Observable } from 'rxjs'; -import { isEmpty } from 'lodash'; +import { compact } from 'lodash'; +import { ESSearchResponse } from 'typings/elasticsearch'; +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { getSeverity } from '../../../common/anomaly_detection'; +import { + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { ANOMALY_SEVERITY } from '../../../../ml/common'; import { KibanaRequest } from '../../../../../../src/core/server'; import { @@ -16,17 +23,11 @@ import { ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, } from '../../../common/alert_types'; -import { AlertingPlugin } from '../../../../alerting/server'; -import { APMConfig } from '../..'; -import { MlPluginSetup } from '../../../../ml/server'; import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; - -interface RegisterAlertParams { - alerting: AlertingPlugin['setup']; - ml?: MlPluginSetup; - config$: Observable; -} +import { RegisterRuleDependencies } from './register_apm_alerts'; +import { parseEnvironmentUrlParam } from '../../../common/environment_filter_values'; +import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type'; const paramsSchema = schema.object({ serviceName: schema.maybe(schema.string()), @@ -46,203 +47,199 @@ const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly]; export function registerTransactionDurationAnomalyAlertType({ - alerting, + registry, ml, - config$, -}: RegisterAlertParams) { - alerting.registerType({ - id: AlertType.TransactionDurationAnomaly, - name: alertTypeConfig.name, - actionGroups: alertTypeConfig.actionGroups, - defaultActionGroupId: alertTypeConfig.defaultActionGroupId, - validate: { - params: paramsSchema, - }, - actionVariables: { - context: [ - apmActionVariables.serviceName, - apmActionVariables.transactionType, - apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, - ], - }, - producer: 'apm', - minimumLicenseRequired: 'basic', - executor: async ({ services, params, state }) => { - if (!ml) { - return; - } - const alertParams = params; - const request = {} as KibanaRequest; - const { mlAnomalySearch } = ml.mlSystemProvider( - request, - services.savedObjectsClient - ); - const anomalyDetectors = ml.anomalyDetectorsProvider( - request, - services.savedObjectsClient - ); - - const mlJobs = await getMLJobs(anomalyDetectors, alertParams.environment); - - const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( - (option) => option.type === alertParams.anomalySeverityType - ); - - if (!selectedOption) { - throw new Error( - `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.` + logger, +}: RegisterRuleDependencies) { + registry.registerType( + createAPMLifecycleRuleType({ + id: AlertType.TransactionDurationAnomaly, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + ], + }, + producer: 'apm', + minimumLicenseRequired: 'basic', + executor: async ({ services, params }) => { + if (!ml) { + return {}; + } + const alertParams = params; + const request = {} as KibanaRequest; + const { mlAnomalySearch } = ml.mlSystemProvider( + request, + services.savedObjectsClient + ); + const anomalyDetectors = ml.anomalyDetectorsProvider( + request, + services.savedObjectsClient ); - } - const threshold = selectedOption.threshold; + const mlJobs = await getMLJobs( + anomalyDetectors, + alertParams.environment + ); - if (mlJobs.length === 0) { - return {}; - } - - const jobIds = mlJobs.map((job) => job.job_id); - const anomalySearchParams = { - terminateAfter: 1, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { result_type: 'record' } }, - { terms: { job_id: jobIds } }, - { - range: { - timestamp: { - gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, - format: 'epoch_millis', + const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( + (option) => option.type === alertParams.anomalySeverityType + ); + + if (!selectedOption) { + throw new Error( + `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.` + ); + } + + const threshold = selectedOption.threshold; + + if (mlJobs.length === 0) { + return {}; + } + + const jobIds = mlJobs.map((job) => job.job_id); + const anomalySearchParams = { + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { result_type: 'record' } }, + { terms: { job_id: jobIds } }, + { term: { is_interim: false } }, + { + range: { + timestamp: { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + format: 'epoch_millis', + }, }, }, - }, - ...(alertParams.serviceName - ? [ - { - term: { - partition_field_value: alertParams.serviceName, + ...(alertParams.serviceName + ? [ + { + term: { + partition_field_value: alertParams.serviceName, + }, }, - }, - ] - : []), - ...(alertParams.transactionType - ? [ - { - term: { - by_field_value: alertParams.transactionType, + ] + : []), + ...(alertParams.transactionType + ? [ + { + term: { + by_field_value: alertParams.transactionType, + }, }, - }, - ] - : []), - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ], - }, - }, - aggs: { - services: { - terms: { - field: 'partition_field_value', - size: 50, + ] + : []), + ] as QueryContainer[], }, - aggs: { - transaction_types: { - terms: { - field: 'by_field_value', - }, + }, + aggs: { + anomaly_groups: { + multi_terms: { + terms: [ + { field: 'partition_field_value' }, + { field: 'by_field_value' }, + { field: 'job_id' }, + ], + size: 10000, }, - record_avg: { - avg: { - field: 'record_score', + aggs: { + latest_score: { + top_metrics: { + metrics: asMutableArray([ + { field: 'record_score' }, + { field: 'partition_field_value' }, + { field: 'by_field_value' }, + { field: 'job_id' }, + ] as const), + sort: { + '@timestamp': 'desc' as const, + }, + }, }, }, }, }, }, - }, - }; - - const response = ((await mlAnomalySearch( - anomalySearchParams, - jobIds - )) as unknown) as { - hits: { total: { value: number } }; - aggregations?: { - services: { - buckets: Array<{ - key: string; - record_avg: { value: number }; - transaction_types: { buckets: Array<{ key: string }> }; - }>; - }; }; - }; - - const hitCount = response.hits.total.value; - - if (hitCount > 0) { - function scheduleAction({ - serviceName, - severity, - environment, - transactionType, - }: { - serviceName: string; - severity: string; - environment?: string; - transactionType?: string; - }) { - const alertInstanceName = [ - AlertType.TransactionDurationAnomaly, - serviceName, - environment, - transactionType, - ] - .filter((name) => name) - .join('_'); - - const alertInstance = services.alertInstanceFactory( - alertInstanceName - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - serviceName, - environment, - transactionType, - threshold: selectedOption?.label, - thresholdValue: severity, - }); - } - mlJobs.map((job) => { - const environment = job.custom_settings?.job_tags?.environment; - response.aggregations?.services.buckets.forEach((serviceBucket) => { - const serviceName = serviceBucket.key as string; - const severity = getSeverity(serviceBucket.record_avg.value); - if (isEmpty(serviceBucket.transaction_types?.buckets)) { - scheduleAction({ serviceName, severity, environment }); - } else { - serviceBucket.transaction_types?.buckets.forEach((typeBucket) => { - const transactionType = typeBucket.key as string; - scheduleAction({ - serviceName, - severity, - environment, - transactionType, - }); - }); - } - }); + const response: ESSearchResponse< + unknown, + typeof anomalySearchParams + > = (await mlAnomalySearch(anomalySearchParams, [])) as any; + + const anomalies = + response.aggregations?.anomaly_groups.buckets + .map((bucket) => { + const latest = bucket.latest_score.top[0].metrics; + + const job = mlJobs.find((j) => j.job_id === latest.job_id); + + if (!job) { + logger.warn( + `Could not find matching job for job id ${latest.job_id}` + ); + return undefined; + } + + return { + serviceName: latest.partition_field_value as string, + transactionType: latest.by_field_value as string, + environment: job.custom_settings!.job_tags!.environment, + score: latest.record_score as number, + }; + }) + .filter((anomaly) => + anomaly ? anomaly.score >= threshold : false + ) ?? []; + + compact(anomalies).forEach((anomaly) => { + const { serviceName, environment, transactionType, score } = anomaly; + + const parsedEnvironment = parseEnvironmentUrlParam(environment); + + services + .alertWithLifecycle({ + id: [ + AlertType.TransactionDurationAnomaly, + serviceName, + environment, + transactionType, + ] + .filter((name) => name) + .join('_'), + fields: { + [SERVICE_NAME]: serviceName, + ...(parsedEnvironment.esFieldValue + ? { [SERVICE_ENVIRONMENT]: environment } + : {}), + [TRANSACTION_TYPE]: transactionType, + }, + }) + .scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + transactionType, + environment, + threshold: selectedOption?.label, + triggerValue: getSeverity(score), + }); }); - } - }, - }); + + return {}; + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts index 148cd813a8a22..be5f4705482d0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts @@ -5,48 +5,19 @@ * 2.0. */ -import { Observable } from 'rxjs'; -import * as Rx from 'rxjs'; -import { toArray, map } from 'rxjs/operators'; -import { AlertingPlugin } from '../../../../alerting/server'; -import { APMConfig } from '../..'; import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; - -type Operator = (source: Rx.Observable) => Rx.Observable; -const pipeClosure = (fn: Operator): Operator => { - return (source: Rx.Observable) => { - return Rx.defer(() => fn(source)); - }; -}; -const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( - pipeClosure((source$) => { - return source$.pipe(map((i) => i)); - }), - toArray() -) as unknown) as Observable; +import { createRuleTypeMocks } from './test_utils'; describe('Transaction error rate alert', () => { it("doesn't send an alert when rate is less than threshold", async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + const { services, dependencies, executor } = createRuleTypeMocks(); registerTransactionErrorRateAlertType({ - alerting, - config$: mockedConfig$, + ...dependencies, }); - expect(alertExecutor).toBeDefined(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(), - }; const params = { threshold: 1 }; services.scopedClusterClient.asCurrentUser.search.mockReturnValue( @@ -60,6 +31,11 @@ describe('Transaction error rate alert', () => { }, took: 0, timed_out: false, + aggregations: { + series: { + buckets: [], + }, + }, _shards: { failed: 0, skipped: 0, @@ -69,30 +45,21 @@ describe('Transaction error rate alert', () => { }) ); - await alertExecutor!({ services, params }); + await executor({ params }); expect(services.alertInstanceFactory).not.toBeCalled(); }); - it('sends alerts with service name, transaction type and environment', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; + it('sends alerts for services that exceeded the threshold', async () => { + const { + services, + dependencies, + executor, + scheduleActions, + } = createRuleTypeMocks(); registerTransactionErrorRateAlertType({ - alerting, - config$: mockedConfig$, + ...dependencies, }); - expect(alertExecutor).toBeDefined(); - - const scheduleActions = jest.fn(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -100,37 +67,38 @@ describe('Transaction error rate alert', () => { hits: [], total: { relation: 'eq', - value: 4, + value: 0, }, }, aggregations: { - failed_transactions: { - doc_count: 2, - }, - services: { + series: { buckets: [ { - key: 'foo', - transaction_types: { + key: ['foo', 'env-foo', 'type-foo'], + outcomes: { buckets: [ { - key: 'type-foo', - environments: { - buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }], - }, + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, }, ], }, }, { - key: 'bar', - transaction_types: { + key: ['bar', 'env-bar', 'type-bar'], + outcomes: { buckets: [ { - key: 'type-bar', - environments: { - buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }], - }, + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, }, ], }, @@ -149,208 +117,25 @@ describe('Transaction error rate alert', () => { }) ); - await alertExecutor!({ services, params }); - [ - 'apm.transaction_error_rate_foo_type-foo_env-foo', - 'apm.transaction_error_rate_foo_type-foo_env-foo-2', - 'apm.transaction_error_rate_bar_type-bar_env-bar', - 'apm.transaction_error_rate_bar_type-bar_env-bar-2', - ].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) - ); - - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: 'type-foo', - environment: 'env-foo', - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: 'type-foo', - environment: 'env-foo-2', - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: 'type-bar', - environment: 'env-bar', - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: 'type-bar', - environment: 'env-bar-2', - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - }); - it('sends alerts with service name and transaction type', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; - - registerTransactionErrorRateAlertType({ - alerting, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); - - const scheduleActions = jest.fn(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; - services.scopedClusterClient.asCurrentUser.search.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - hits: { - hits: [], - total: { - relation: 'eq', - value: 4, - }, - }, - aggregations: { - failed_transactions: { - doc_count: 2, - }, - services: { - buckets: [ - { - key: 'foo', - transaction_types: { - buckets: [{ key: 'type-foo' }], - }, - }, - { - key: 'bar', - transaction_types: { - buckets: [{ key: 'type-bar' }], - }, - }, - ], - }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - skipped: 0, - successful: 1, - total: 1, - }, - }) - ); - - await alertExecutor!({ services, params }); - [ - 'apm.transaction_error_rate_foo_type-foo', - 'apm.transaction_error_rate_bar_type-bar', - ].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) - ); - - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: 'type-foo', - environment: undefined, - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: 'type-bar', - environment: undefined, - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - }); - - it('sends alerts with service name', async () => { - let alertExecutor: any; - const alerting = { - registerType: ({ executor }) => { - alertExecutor = executor; - }, - } as AlertingPlugin['setup']; - - registerTransactionErrorRateAlertType({ - alerting, - config$: mockedConfig$, - }); - expect(alertExecutor).toBeDefined(); + await executor({ params }); - const scheduleActions = jest.fn(); - const services = { - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + expect(services.alertInstanceFactory).toHaveBeenCalledTimes(1); - services.scopedClusterClient.asCurrentUser.search.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - hits: { - hits: [], - total: { - value: 4, - relation: 'eq', - }, - }, - aggregations: { - failed_transactions: { - doc_count: 2, - }, - services: { - buckets: [{ key: 'foo' }, { key: 'bar' }], - }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - skipped: 0, - successful: 1, - total: 1, - }, - }) + expect(services.alertInstanceFactory).toHaveBeenCalledWith( + 'apm.transaction_error_rate_foo_type-foo_env-foo' ); - - await alertExecutor!({ services, params }); - [ - 'apm.transaction_error_rate_foo', - 'apm.transaction_error_rate_bar', - ].forEach((instanceName) => - expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + expect(services.alertInstanceFactory).not.toHaveBeenCalledWith( + 'apm.transaction_error_rate_bar_type-bar_env-bar' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', - transactionType: undefined, - environment: undefined, - threshold: 10, - triggerValue: '50', - interval: '5m', - }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'bar', - transactionType: undefined, - environment: undefined, + transactionType: 'type-foo', + environment: 'env-foo', threshold: 10, - triggerValue: '50', + triggerValue: '10', interval: '5m', }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 0b2684cdaf083..0865bed41142e 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -6,11 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { isEmpty } from 'lodash'; -import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { APMConfig } from '../..'; -import { AlertingPlugin } from '../../../../alerting/server'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { EVENT_OUTCOME, @@ -26,11 +22,8 @@ import { environmentQuery } from '../../../server/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; - -interface RegisterAlertParams { - alerting: AlertingPlugin['setup']; - config$: Observable; -} +import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type'; +import { RegisterRuleDependencies } from './register_apm_alerts'; const paramsSchema = schema.object({ windowSize: schema.number(), @@ -44,158 +37,165 @@ const paramsSchema = schema.object({ const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionErrorRate]; export function registerTransactionErrorRateAlertType({ - alerting, + registry, config$, -}: RegisterAlertParams) { - alerting.registerType({ - id: AlertType.TransactionErrorRate, - name: alertTypeConfig.name, - actionGroups: alertTypeConfig.actionGroups, - defaultActionGroupId: alertTypeConfig.defaultActionGroupId, - validate: { - params: paramsSchema, - }, - actionVariables: { - context: [ - apmActionVariables.transactionType, - apmActionVariables.serviceName, - apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, - apmActionVariables.interval, - ], - }, - producer: 'apm', - minimumLicenseRequired: 'basic', - executor: async ({ services, params: alertParams }) => { - const config = await config$.pipe(take(1)).toPromise(); - const indices = await getApmIndices({ - config, - savedObjectsClient: services.savedObjectsClient, - }); - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; +}: RegisterRuleDependencies) { + registry.registerType( + createAPMLifecycleRuleType({ + id: AlertType.TransactionErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.transactionType, + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + apmActionVariables.interval, + ], + }, + producer: 'apm', + minimumLicenseRequired: 'basic', + executor: async ({ services, params: alertParams }) => { + const config = await config$.pipe(take(1)).toPromise(); + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient, + }); - const searchParams = { - index: indices['apm_oss.transactionIndices'], - size: 0, - body: { - track_total_hits: true, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 1, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + }, }, }, - }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ...(alertParams.serviceName - ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] - : []), - ...(alertParams.transactionType - ? [ - { - term: { - [TRANSACTION_TYPE]: alertParams.transactionType, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { + terms: { + [EVENT_OUTCOME]: [ + EventOutcome.failure, + EventOutcome.success, + ], + }, + }, + ...(alertParams.serviceName + ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] + : []), + ...(alertParams.transactionType + ? [ + { + term: { + [TRANSACTION_TYPE]: alertParams.transactionType, + }, }, - }, - ] - : []), - ...environmentQuery(alertParams.environment), - ], - }, - }, - aggs: { - failed_transactions: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - }, - services: { - terms: { - field: SERVICE_NAME, - size: 50, + ] + : []), + ...environmentQuery(alertParams.environment), + ], }, - aggs: { - transaction_types: { - terms: { field: TRANSACTION_TYPE }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - size: maxServiceEnvironments, - }, + }, + aggs: { + series: { + multi_terms: { + terms: [ + { field: SERVICE_NAME }, + { field: SERVICE_ENVIRONMENT, missing: '' }, + { field: TRANSACTION_TYPE }, + ], + size: 10000, + }, + aggs: { + outcomes: { + terms: { + field: EVENT_OUTCOME, }, }, }, }, }, }, - }, - }; + }; - const { body: response } = await alertingEsClient(services, searchParams); - if (!response.aggregations) { - return; - } + const response = await alertingEsClient( + services.scopedClusterClient, + searchParams + ); - const failedTransactionCount = - response.aggregations.failed_transactions.doc_count; - const totalTransactionCount = response.hits.total.value; - const transactionErrorRate = - (failedTransactionCount / totalTransactionCount) * 100; + if (!response.aggregations) { + return {}; + } - if (transactionErrorRate > alertParams.threshold) { - function scheduleAction({ - serviceName, - environment, - transactionType, - }: { - serviceName: string; - environment?: string; - transactionType?: string; - }) { - const alertInstanceName = [ - AlertType.TransactionErrorRate, - serviceName, - transactionType, - environment, - ] - .filter((name) => name) - .join('_'); + const results = response.aggregations.series.buckets + .map((bucket) => { + const [serviceName, environment, transactionType] = bucket.key; + + const failed = + bucket.outcomes.buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.doc_count ?? 0; + const succesful = + bucket.outcomes.buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.success + )?.doc_count ?? 0; - const alertInstance = services.alertInstanceFactory( - alertInstanceName - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + return { + serviceName, + environment, + transactionType, + errorRate: (failed / (failed + succesful)) * 100, + }; + }) + .filter((result) => result.errorRate >= alertParams.threshold); + + results.forEach((result) => { + const { serviceName, - transactionType, environment, - threshold: alertParams.threshold, - triggerValue: asDecimalOrInteger(transactionErrorRate), - interval: `${alertParams.windowSize}${alertParams.windowUnit}`, - }); - } + transactionType, + errorRate, + } = result; - response.aggregations?.services.buckets.forEach((serviceBucket) => { - const serviceName = serviceBucket.key as string; - if (isEmpty(serviceBucket.transaction_types?.buckets)) { - scheduleAction({ serviceName }); - } else { - serviceBucket.transaction_types.buckets.forEach((typeBucket) => { - const transactionType = typeBucket.key as string; - if (isEmpty(typeBucket.environments?.buckets)) { - scheduleAction({ serviceName, transactionType }); - } else { - typeBucket.environments.buckets.forEach((envBucket) => { - const environment = envBucket.key as string; - scheduleAction({ serviceName, transactionType, environment }); - }); - } + services + .alertWithLifecycle({ + id: [ + AlertType.TransactionErrorRate, + serviceName, + transactionType, + environment, + ] + .filter((name) => name) + .join('_'), + fields: { + [SERVICE_NAME]: serviceName, + ...(environment ? { [SERVICE_ENVIRONMENT]: environment } : {}), + [TRANSACTION_TYPE]: transactionType, + }, + }) + .scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + transactionType, + environment, + threshold: alertParams.threshold, + triggerValue: asDecimalOrInteger(errorRate), + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, }); - } }); - } - }, - }); + + return {}; + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts new file mode 100644 index 0000000000000..37b3e282d0a59 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'kibana/server'; +import { of } from 'rxjs'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { APMConfig } from '../../..'; +import { APMRuleRegistry } from '../../../plugin'; + +export const createRuleTypeMocks = () => { + let alertExecutor: (...args: any[]) => Promise; + + const mockedConfig$ = of({ + /* eslint-disable @typescript-eslint/naming-convention */ + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.transactionIndices': 'apm-*', + /* eslint-enable @typescript-eslint/naming-convention */ + } as APMConfig); + + const loggerMock = ({ + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown) as Logger; + + const registry = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as APMRuleRegistry; + + const scheduleActions = jest.fn(); + + const services = { + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + scopedRuleRegistryClient: { + bulkIndex: jest.fn(), + }, + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + alertWithLifecycle: jest.fn(), + logger: loggerMock, + }; + + return { + dependencies: { + registry, + config$: mockedConfig$, + logger: loggerMock, + }, + services, + scheduleActions, + executor: async ({ params }: { params: Record }) => { + return alertExecutor({ + services, + params, + startedAt: new Date(), + }); + }, + }; +}; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index 1f0aa401bcab0..989297544c78f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -10,7 +10,7 @@ import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; -import { inspectableEsQueriesMap } from '../../../routes/create_api'; +import { inspectableEsQueriesMap } from '../../../routes/register_routes'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts index 45e17c1678518..9d7434d127ead 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { CreateIndexRequest, @@ -13,7 +12,7 @@ import { IndexRequest, } from '@elastic/elasticsearch/api/types'; import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../../routes/typings'; import { ESSearchResponse, ESSearchRequest, @@ -31,11 +30,9 @@ export type APMInternalClient = ReturnType; export function createInternalESClient({ context, + debug, request, -}: { - context: APMRequestHandlerContext; - request: KibanaRequest; -}) { +}: Pick & { debug: boolean }) { const { asInternalUser } = context.core.elasticsearch.client; function callEs({ @@ -53,7 +50,7 @@ export function createInternalESClient({ title: getDebugTitle(request), body: getDebugBody(params, requestType), }), - debug: context.params.query._inspect, + debug, isCalledWithInternalUser: true, request, requestType, diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index c0707d0286180..c0ff0cab88f47 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -7,8 +7,7 @@ import { setupRequest } from './setup_request'; import { APMConfig } from '../..'; -import { APMRequestHandlerContext } from '../../routes/typings'; -import { KibanaRequest } from '../../../../../../src/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ProcessorEvent } from '../../../common/processor_event'; import { PROCESSOR_EVENT } from '../../../common/elasticsearch_fieldnames'; @@ -32,7 +31,7 @@ jest.mock('../index_pattern/get_dynamic_index_pattern', () => ({ }, })); -function getMockRequest() { +function getMockResources() { const esClientMock = { asCurrentUser: { search: jest.fn().mockResolvedValue({ body: {} }), @@ -42,7 +41,7 @@ function getMockRequest() { }, }; - const mockContext = ({ + const mockResources = ({ config: new Proxy( {}, { @@ -54,65 +53,69 @@ function getMockRequest() { _inspect: false, }, }, - core: { - elasticsearch: { - client: esClientMock, - }, - uiSettings: { - client: { - get: jest.fn().mockResolvedValue(false), + context: { + core: { + elasticsearch: { + client: esClientMock, }, - }, - savedObjects: { - client: { - get: jest.fn(), + uiSettings: { + client: { + get: jest.fn().mockResolvedValue(false), + }, + }, + savedObjects: { + client: { + get: jest.fn(), + }, }, }, }, plugins: { ml: undefined, }, - } as unknown) as APMRequestHandlerContext & { - core: { - elasticsearch: { - client: typeof esClientMock; - }; - uiSettings: { - client: { - get: jest.Mock; + request: { + url: '', + events: { + aborted$: { + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + }, + }, + }, + } as unknown) as APMRouteHandlerResources & { + context: { + core: { + elasticsearch: { + client: typeof esClientMock; }; - }; - savedObjects: { - client: { - get: jest.Mock; + uiSettings: { + client: { + get: jest.Mock; + }; + }; + savedObjects: { + client: { + get: jest.Mock; + }; }; }; }; }; - const mockRequest = ({ - url: '', - events: { - aborted$: { - subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), - }, - }, - } as unknown) as KibanaRequest; - - return { mockContext, mockRequest }; + return mockResources; } describe('setupRequest', () => { describe('with default args', () => { it('calls callWithRequest', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction] }, body: { foo: 'bar' }, }); + expect( - mockContext.core.elasticsearch.client.asCurrentUser.search + mockResources.context.core.elasticsearch.client.asCurrentUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -132,14 +135,14 @@ describe('setupRequest', () => { }); it('calls callWithInternalUser', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { internalClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { internalClient } = await setupRequest(mockResources); await internalClient.search({ index: ['apm-*'], body: { foo: 'bar' }, } as any); expect( - mockContext.core.elasticsearch.client.asInternalUser.search + mockResources.context.core.elasticsearch.client.asInternalUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -151,8 +154,8 @@ describe('setupRequest', () => { describe('with a bool filter', () => { it('adds a range filter for `observer.version_major` to the existing filter', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction], @@ -162,8 +165,8 @@ describe('setupRequest', () => { }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -178,8 +181,8 @@ describe('setupRequest', () => { }); it('does not add a range filter for `observer.version_major` if includeLegacyData=true', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search( { apm: { @@ -194,8 +197,8 @@ describe('setupRequest', () => { } ); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -216,15 +219,15 @@ describe('setupRequest', () => { describe('without a bool filter', () => { it('adds a range filter for `observer.version_major`', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.error], }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.body).toEqual({ query: { @@ -241,12 +244,12 @@ describe('without a bool filter', () => { describe('with includeFrozen=false', () => { it('sets `ignore_throttled=true`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return false - mockContext.core.uiSettings.client.get.mockResolvedValue(false); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(false); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { @@ -255,7 +258,7 @@ describe('with includeFrozen=false', () => { }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(true); }); @@ -263,19 +266,19 @@ describe('with includeFrozen=false', () => { describe('with includeFrozen=true', () => { it('sets `ignore_throttled=false`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return true - mockContext.core.uiSettings.client.get.mockResolvedValue(true); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(true); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [] }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(false); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index fff661250c6df..40836cb6635e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -11,7 +11,7 @@ import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { UIFilters } from '../../../typings/ui_filters'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ApmIndicesConfig, getApmIndices, @@ -44,7 +44,7 @@ export interface SetupTimeRange { } interface SetupRequestParams { - query?: { + query: { _inspect?: boolean; /** @@ -64,13 +64,19 @@ type InferSetup = Setup & (TParams extends { query: { start: number } } ? { start: number } : {}) & (TParams extends { query: { end: number } } ? { end: number } : {}); -export async function setupRequest( - context: APMRequestHandlerContext, - request: KibanaRequest -): Promise> { +export async function setupRequest({ + context, + params, + core, + plugins, + request, + config, + logger, +}: APMRouteHandlerResources & { + params: TParams; +}): Promise> { return withApmSpan('setup_request', async () => { - const { config, logger } = context; - const { query } = context.params; + const { query } = params; const [indices, includeFrozen] = await Promise.all([ getApmIndices({ @@ -88,7 +94,7 @@ export async function setupRequest( indices, apmEventClient: createApmEventClient({ esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._inspect, + debug: query._inspect, request, indices, options: { includeFrozen }, @@ -96,11 +102,12 @@ export async function setupRequest( internalClient: createInternalESClient({ context, request, + debug: query._inspect, }), ml: - context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + plugins.ml && isActivePlatinumLicense(context.licensing.license) ? getMlSetup( - context.plugins.ml, + plugins.ml.setup, context.core.savedObjects.client, request ) @@ -118,8 +125,8 @@ export async function setupRequest( } function getMlSetup( - ml: Required['ml'], - savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'], + ml: Required['ml']['setup'], + savedObjectsClient: APMRouteHandlerResources['context']['core']['savedObjects']['client'], request: KibanaRequest ) { return { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts index 19163da449b90..a5340c1220b44 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts @@ -8,21 +8,9 @@ import { createStaticIndexPattern } from './create_static_index_pattern'; import { Setup } from '../helpers/setup_request'; import * as HistoricalAgentData from '../services/get_services/has_historical_agent_data'; -import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; +import { APMConfig } from '../..'; -function getMockContext(config: Record) { - return ({ - config, - core: { - savedObjects: { - client: { - create: jest.fn(), - }, - }, - }, - } as unknown) as APMRequestHandlerContext; -} function getMockSavedObjectsClient() { return ({ create: jest.fn(), @@ -32,13 +20,13 @@ function getMockSavedObjectsClient() { describe('createStaticIndexPattern', () => { it(`should not create index pattern if 'xpack.apm.autocreateApmIndexPattern=false'`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': false, - }); + const savedObjectsClient = getMockSavedObjectsClient(); await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': false, + } as APMConfig, savedObjectsClient, 'default' ); @@ -47,9 +35,6 @@ describe('createStaticIndexPattern', () => { it(`should not create index pattern if no APM data is found`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does not have APM data jest @@ -60,7 +45,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); @@ -69,9 +56,6 @@ describe('createStaticIndexPattern', () => { it(`should create index pattern`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does have APM data jest @@ -82,7 +66,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index b91fb8342a212..e627e9ed1d6cf 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -12,20 +12,18 @@ import { } from '../../../../../../src/plugins/apm_oss/server'; import { hasHistoricalAgentData } from '../services/get_services/has_historical_agent_data'; import { Setup } from '../helpers/setup_request'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; import { withApmSpan } from '../../utils/with_apm_span'; import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( setup: Setup, - context: APMRequestHandlerContext, + config: APMRouteHandlerResources['config'], savedObjectsClient: InternalSavedObjectsClient, spaceId: string | undefined ): Promise { return withApmSpan('create_static_index_pattern', async () => { - const { config } = context; - // don't autocreate APM index pattern if it's been disabled via the config if (!config['xpack.apm.autocreateApmIndexPattern']) { return false; @@ -39,7 +37,7 @@ export async function createStaticIndexPattern( } try { - const apmIndexPatternTitle = getApmIndexPatternTitle(context); + const apmIndexPatternTitle = getApmIndexPatternTitle(config); await withApmSpan('create_index_pattern_saved_object', () => savedObjectsClient.create( 'index-pattern', diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts index 41abe82de8ff2..faec64c798c7d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; -export function getApmIndexPatternTitle(context: APMRequestHandlerContext) { - return context.config['apm_oss.indexPattern']; +export function getApmIndexPatternTitle( + config: APMRouteHandlerResources['config'] +) { + return config['apm_oss.indexPattern']; } diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index 5d5e6eebb4c9f..8bbc22fbf289d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -9,7 +9,7 @@ import { IndexPatternsFetcher, FieldDescriptor, } from '../../../../../../src/plugins/data/server'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { withApmSpan } from '../../utils/with_apm_span'; export interface IndexPatternTitleAndFields { @@ -20,12 +20,12 @@ export interface IndexPatternTitleAndFields { // TODO: this is currently cached globally. In the future we might want to cache this per user export const getDynamicIndexPattern = ({ + config, context, -}: { - context: APMRequestHandlerContext; -}) => { + logger, +}: Pick) => { return withApmSpan('get_dynamic_index_pattern', async () => { - const indexPatternTitle = context.config['apm_oss.indexPattern']; + const indexPatternTitle = config['apm_oss.indexPattern']; const indexPatternsFetcher = new IndexPatternsFetcher( context.core.elasticsearch.client.asCurrentUser @@ -50,7 +50,7 @@ export const getDynamicIndexPattern = ({ } catch (e) { const notExists = e.output?.statusCode === 404; if (notExists) { - context.logger.error( + logger.error( `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` ); return; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index a1587611b0a2a..d8dbc242986a6 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -14,7 +14,7 @@ import { APM_INDICES_SAVED_OBJECT_ID, } from '../../../../common/apm_saved_object_constants'; import { APMConfig } from '../../..'; -import { APMRequestHandlerContext } from '../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../routes/typings'; import { withApmSpan } from '../../../utils/with_apm_span'; type ISavedObjectsClient = Pick; @@ -91,9 +91,8 @@ const APM_UI_INDICES: ApmIndicesName[] = [ export async function getApmIndexSettings({ context, -}: { - context: APMRequestHandlerContext; -}) { + config, +}: Pick) { let apmIndicesSavedObject: PromiseReturnType; try { apmIndicesSavedObject = await getApmIndicesSavedObject( @@ -106,7 +105,7 @@ export async function getApmIndexSettings({ throw error; } } - const apmIndicesConfig = getApmIndicesConfig(context.config); + const apmIndicesConfig = getApmIndicesConfig(config); return APM_UI_INDICES.map((configurationName) => ({ configurationName, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db96794627519..cb94b18a1ecf9 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { combineLatest, Observable } from 'rxjs'; +import { combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { CoreSetup, @@ -16,22 +16,10 @@ import { Plugin, PluginInitializerContext, } from 'src/core/server'; -import { SpacesPluginSetup } from '../../spaces/server'; +import { mapValues } from 'lodash'; import { APMConfig, APMXPackConfig } from '.'; import { mergeConfigs } from './index'; -import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; -import { ActionsPlugin } from '../../actions/server'; -import { AlertingPlugin } from '../../alerting/server'; -import { CloudSetup } from '../../cloud/server'; -import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; -import { MlPluginSetup } from '../../ml/server'; -import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; @@ -40,23 +28,31 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { createApmApi } from './routes/create_apm_api'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; import { uiSettings } from './ui_settings'; -import type { ApmPluginRequestHandlerContext } from './routes/typings'; - -export interface APMPluginSetup { - config$: Observable; - getApmIndices: () => ReturnType; - createApmEventClient: (params: { - debug?: boolean; - request: KibanaRequest; - context: ApmPluginRequestHandlerContext; - }) => Promise>; -} - -export class APMPlugin implements Plugin { +import type { + ApmPluginRequestHandlerContext, + APMRouteHandlerResources, +} from './routes/typings'; +import { + APMPluginSetup, + APMPluginSetupDependencies, + APMPluginStartDependencies, +} from './types'; +import { registerRoutes } from './routes/register_routes'; +import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; + +export type APMRuleRegistry = ReturnType['ruleRegistry']; + +export class APMPlugin + implements + Plugin< + APMPluginSetup, + void, + APMPluginSetupDependencies, + APMPluginStartDependencies + > { private currentConfig?: APMConfig; private logger?: Logger; constructor(private readonly initContext: PluginInitializerContext) { @@ -64,22 +60,8 @@ export class APMPlugin implements Plugin { } public setup( - core: CoreSetup, - plugins: { - spaces?: SpacesPluginSetup; - apmOss: APMOSSPluginSetup; - home: HomeServerPluginSetup; - licensing: LicensingPluginSetup; - cloud?: CloudSetup; - usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; - alerting?: AlertingPlugin['setup']; - actions?: ActionsPlugin['setup']; - observability?: ObservabilityPluginSetup; - features: FeaturesPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - } + core: CoreSetup, + plugins: Omit ) { this.logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); @@ -92,20 +74,13 @@ export class APMPlugin implements Plugin { core.uiSettings.register(uiSettings); - if (plugins.actions && plugins.alerting) { - registerApmAlerts({ - alerting: plugins.alerting, - actions: plugins.actions, - ml: plugins.ml, - config$: mergedConfig$, - }); - } - - this.currentConfig = mergeConfigs( + const currentConfig = mergeConfigs( plugins.apmOss.config, this.initContext.config.get() ); + this.currentConfig = currentConfig; + if ( plugins.taskManager && plugins.usageCollection && @@ -122,8 +97,8 @@ export class APMPlugin implements Plugin { } const ossTutorialProvider = plugins.apmOss.getRegisteredTutorialProvider(); - plugins.home.tutorials.unregisterTutorial(ossTutorialProvider); - plugins.home.tutorials.registerTutorial(() => { + plugins.home?.tutorials.unregisterTutorial(ossTutorialProvider); + plugins.home?.tutorials.registerTutorial(() => { const ossPart = ossTutorialProvider({}); if (this.currentConfig!['xpack.apm.ui.enabled'] && ossPart.artifacts) { ossPart.artifacts.application = { @@ -147,10 +122,26 @@ export class APMPlugin implements Plugin { registerFeaturesUsage({ licensingPlugin: plugins.licensing }); - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins, + registerRoutes({ + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + logger: this.logger, + config: currentConfig, + repository: getGlobalApmServerRouteRepository(), + plugins: mapValues(plugins, (value, key) => { + return { + setup: value, + start: () => + core.getStartServices().then((services) => { + const [, pluginsStartContracts] = services; + return pluginsStartContracts[ + key as keyof APMPluginStartDependencies + ]; + }), + }; + }) as APMRouteHandlerResources['plugins'], }); const boundGetApmIndices = async () => @@ -159,6 +150,28 @@ export class APMPlugin implements Plugin { config: await mergedConfig$.pipe(take(1)).toPromise(), }); + const apmRuleRegistry = plugins.observability.ruleRegistry.create({ + name: 'apm', + fieldMap: { + 'service.environment': { + type: 'keyword', + }, + 'transaction.type': { + type: 'keyword', + }, + 'processor.event': { + type: 'keyword', + }, + }, + }); + + registerApmAlerts({ + registry: apmRuleRegistry, + ml: plugins.ml, + config$: mergedConfig$, + logger: this.logger!.get('rule'), + }); + return { config$: mergedConfig$, getApmIndices: boundGetApmIndices, @@ -188,6 +201,7 @@ export class APMPlugin implements Plugin { }, }); }, + ruleRegistry: apmRuleRegistry, }; } diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts index 3bebcd49ec34a..0175860e93d35 100644 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -10,7 +10,8 @@ import { getTransactionDurationChartPreview } from '../../lib/alerts/chart_previ import { getTransactionErrorCountChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_count'; import { getTransactionErrorRateChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_rate'; import { setupRequest } from '../../lib/helpers/setup_request'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; import { rangeRt } from '../default_api_types'; const alertParamsRt = t.intersection([ @@ -29,13 +30,14 @@ const alertParamsRt = t.intersection([ export type AlertParams = t.TypeOf; -export const transactionErrorRateChartPreview = createRoute({ +const transactionErrorRateChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { _inspect, ...alertParams } = params.query; const errorRateChartPreview = await getTransactionErrorRateChartPreview({ setup, @@ -46,13 +48,16 @@ export const transactionErrorRateChartPreview = createRoute({ }, }); -export const transactionErrorCountChartPreview = createRoute({ +const transactionErrorCountChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; + const errorCountChartPreview = await getTransactionErrorCountChartPreview({ setup, alertParams, @@ -62,13 +67,16 @@ export const transactionErrorCountChartPreview = createRoute({ }, }); -export const transactionDurationChartPreview = createRoute({ +const transactionDurationChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; const latencyChartPreview = await getTransactionDurationChartPreview({ alertParams, @@ -78,3 +86,9 @@ export const transactionDurationChartPreview = createRoute({ return { latencyChartPreview }; }, }); + +export const alertsChartPreviewRouteRepository = createApmServerRouteRepository() + .add(transactionErrorRateChartPreview) + .add(transactionDurationChartPreview) + .add(transactionErrorCountChartPreview) + .add(transactionDurationChartPreview); diff --git a/x-pack/plugins/apm/server/routes/correlations.ts b/x-pack/plugins/apm/server/routes/correlations.ts index c7c69e0774822..4728aa2e8d3f6 100644 --- a/x-pack/plugins/apm/server/routes/correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations.ts @@ -14,7 +14,8 @@ import { getOverallErrorTimeseries } from '../lib/correlations/errors/get_overal import { getCorrelationsForSlowTransactions } from '../lib/correlations/latency/get_correlations_for_slow_transactions'; import { getOverallLatencyDistribution } from '../lib/correlations/latency/get_overall_latency_distribution'; import { setupRequest } from '../lib/helpers/setup_request'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; const INVALID_LICENSE = i18n.translate( @@ -25,7 +26,7 @@ const INVALID_LICENSE = i18n.translate( } ); -export const correlationsLatencyDistributionRoute = createRoute({ +const correlationsLatencyDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/overall_distribution', params: t.type({ query: t.intersection([ @@ -40,18 +41,19 @@ export const correlationsLatencyDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallLatencyDistribution({ environment, @@ -64,7 +66,7 @@ export const correlationsLatencyDistributionRoute = createRoute({ }, }); -export const correlationsForSlowTransactionsRoute = createRoute({ +const correlationsForSlowTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/slow_transactions', params: t.type({ query: t.intersection([ @@ -85,11 +87,13 @@ export const correlationsForSlowTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -100,7 +104,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ fieldNames, maxLatency, distributionInterval, - } = context.params.query; + } = params.query; return getCorrelationsForSlowTransactions({ environment, @@ -117,7 +121,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ }, }); -export const correlationsErrorDistributionRoute = createRoute({ +const correlationsErrorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/overall_timeseries', params: t.type({ query: t.intersection([ @@ -132,18 +136,20 @@ export const correlationsErrorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallErrorTimeseries({ environment, @@ -156,7 +162,7 @@ export const correlationsErrorDistributionRoute = createRoute({ }, }); -export const correlationsForFailedTransactionsRoute = createRoute({ +const correlationsForFailedTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/failed_transactions', params: t.type({ query: t.intersection([ @@ -174,11 +180,12 @@ export const correlationsForFailedTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -186,7 +193,7 @@ export const correlationsForFailedTransactionsRoute = createRoute({ transactionType, transactionName, fieldNames, - } = context.params.query; + } = params.query; return getCorrelationsForFailedTransactions({ environment, @@ -199,3 +206,9 @@ export const correlationsForFailedTransactionsRoute = createRoute({ }); }, }); + +export const correlationsRouteRepository = createApmServerRouteRepository() + .add(correlationsLatencyDistributionRoute) + .add(correlationsForSlowTransactionsRoute) + .add(correlationsErrorDistributionRoute) + .add(correlationsForFailedTransactionsRoute); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts deleted file mode 100644 index 9958b8dec0124..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ /dev/null @@ -1,368 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; -import { createApi } from './index'; -import { CoreSetup, Logger } from 'src/core/server'; -import { RouteParamsRT } from '../typings'; -import { BehaviorSubject } from 'rxjs'; -import { APMConfig } from '../..'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; - -const getCoreMock = () => { - const get = jest.fn(); - const post = jest.fn(); - const put = jest.fn(); - const createRouter = jest.fn().mockReturnValue({ - get, - post, - put, - }); - - const mock = {} as CoreSetup; - - return { - mock: { - ...mock, - http: { - ...mock.http, - createRouter, - }, - }, - get, - post, - put, - createRouter, - context: { - measure: () => undefined, - config$: new BehaviorSubject({} as APMConfig), - logger: ({ - error: jest.fn(), - } as unknown) as Logger, - plugins: {}, - }, - }; -}; - -const initApi = (params?: RouteParamsRT) => { - const { mock, context, createRouter, get, post } = getCoreMock(); - const handlerMock = jest.fn(); - createApi() - .add(() => ({ - endpoint: 'GET /foo', - params, - options: { tags: ['access:apm'] }, - handler: handlerMock, - })) - .init(mock, context); - - const routeHandler = get.mock.calls[0][1]; - - const responseMock = { - ok: jest.fn(), - custom: jest.fn(), - }; - - const simulateRequest = (requestMock: any) => { - return routeHandler( - {}, - { - // stub default values - params: {}, - query: {}, - body: null, - ...requestMock, - }, - responseMock - ); - }; - - return { - simulateRequest, - handlerMock, - createRouter, - get, - post, - responseMock, - }; -}; - -describe('createApi', () => { - it('registers a route with the server', () => { - const { mock, context, createRouter, post, get, put } = getCoreMock(); - - createApi() - .add(() => ({ - endpoint: 'GET /foo', - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'POST /bar', - params: t.type({ - body: t.string, - }), - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'PUT /baz', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - })) - .add({ - endpoint: 'GET /qux', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - }) - .init(mock, context); - - expect(createRouter).toHaveBeenCalledTimes(1); - - expect(get).toHaveBeenCalledTimes(2); - expect(post).toHaveBeenCalledTimes(1); - expect(put).toHaveBeenCalledTimes(1); - - expect(get.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/foo', - validate: expect.anything(), - }); - - expect(get.mock.calls[1][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/qux', - validate: expect.anything(), - }); - - expect(post.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/bar', - validate: expect.anything(), - }); - - expect(put.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/baz', - validate: expect.anything(), - }); - }); - - describe('when validating', () => { - describe('_inspect', () => { - it('allows _inspect=true', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 'true' } }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: true } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ - body: { _inspect: [] }, - }); - }); - - it('rejects _inspect=1', async () => { - const { simulateRequest, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 1 } }); - - // responds with error handler - expect(responseMock.ok).not.toHaveBeenCalled(); - expect(responseMock.custom).toHaveBeenCalledWith({ - body: { - attributes: { _inspect: [] }, - message: - 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', - }, - statusCode: 400, - }); - }); - - it('allows omitting _inspect', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: {} }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: false } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ body: {} }); - }); - }); - - it('throws if unknown parameters are provided', async () => { - const { simulateRequest, responseMock } = initApi(); - - await simulateRequest({ - query: { _inspect: true, extra: '' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - body: { foo: 'bar' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates path parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - path: t.type({ - foo: t.string, - }), - }) - ); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(handlerMock).toHaveBeenCalledTimes(1); - - expect(responseMock.ok).toHaveBeenCalledTimes(1); - expect(responseMock.custom).not.toHaveBeenCalled(); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - path: { - foo: 'bar', - }, - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - params: { - bar: 'foo', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - params: { - foo: 9, - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - extra: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates body parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - body: t.string, - }) - ); - - await simulateRequest({ - body: '', - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - body: '', - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - body: null, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - - it('validates query parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - query: t.type({ - bar: t.string, - filterNames: jsonRt.pipe(t.array(t.string)), - }), - }) - ); - - await simulateRequest({ - query: { - bar: '', - _inspect: 'true', - filterNames: JSON.stringify(['hostName', 'agentName']), - }, - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - query: { - bar: '', - _inspect: true, - filterNames: ['hostName', 'agentName'], - }, - }); - - await simulateRequest({ - query: { - bar: '', - foo: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts deleted file mode 100644 index 87bc97d346984..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ /dev/null @@ -1,185 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { merge as mergeLodash, pickBy, isEmpty, isPlainObject } from 'lodash'; -import Boom from '@hapi/boom'; -import { schema } from '@kbn/config-schema'; -import * as t from 'io-ts'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaRequest, RouteRegistrar } from 'src/core/server'; -import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; -import agent from 'elastic-apm-node'; -import { parseMethod } from '../../../common/apm_api/parse_endpoint'; -import { merge } from '../../../common/runtime_types/merge'; -import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; -import { APMConfig } from '../..'; -import { InspectResponse, RouteParamsRT, ServerAPI } from '../typings'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; -import type { ApmPluginRequestHandlerContext } from '../typings'; - -const inspectRt = t.exact( - t.partial({ - query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), - }) -); - -type RouteOrRouteFactoryFn = Parameters['add']>[0]; - -const isNotEmpty = (val: any) => - val !== undefined && val !== null && !(isPlainObject(val) && isEmpty(val)); - -export const inspectableEsQueriesMap = new WeakMap< - KibanaRequest, - InspectResponse ->(); - -export function createApi() { - const routes: RouteOrRouteFactoryFn[] = []; - const api: ServerAPI<{}> = { - _S: {}, - add(route) { - routes.push((route as unknown) as RouteOrRouteFactoryFn); - return this as any; - }, - init(core, { config$, logger, plugins }) { - const router = core.http.createRouter(); - - let config = {} as APMConfig; - - config$.subscribe((val) => { - config = val; - }); - - routes.forEach((routeOrFactoryFn) => { - const route = - typeof routeOrFactoryFn === 'function' - ? routeOrFactoryFn(core) - : routeOrFactoryFn; - - const { params, endpoint, options, handler } = route; - - const [method, path] = endpoint.split(' '); - const typedRouterMethod = parseMethod(method); - - // For all runtime types with props, we create an exact - // version that will strip all keys that are unvalidated. - const anyObject = schema.object({}, { unknowns: 'allow' }); - - (router[typedRouterMethod] as RouteRegistrar< - typeof typedRouterMethod, - ApmPluginRequestHandlerContext - >)( - { - path, - options, - validate: { - // `body` can be null, but `validate` expects non-nullable types - // if any validation is defined. Not having validation currently - // means we don't get the payload. See - // https://github.com/elastic/kibana/issues/50179 - body: schema.nullable(anyObject), - params: anyObject, - query: anyObject, - }, - }, - async (context, request, response) => { - if (agent.isStarted()) { - agent.addLabels({ - plugin: 'apm', - }); - } - - // init debug queries - inspectableEsQueriesMap.set(request, []); - - try { - const validParams = validateParams(request, params); - const data = await handler({ - request, - context: { - ...context, - plugins, - params: validParams, - config, - logger, - }, - }); - - const body = { ...data }; - if (validParams.query._inspect) { - body._inspect = inspectableEsQueriesMap.get(request); - } - - // cleanup - inspectableEsQueriesMap.delete(request); - - return response.ok({ body }); - } catch (error) { - logger.error(error); - const opts = { - statusCode: 500, - body: { - message: error.message, - attributes: { - _inspect: inspectableEsQueriesMap.get(request), - }, - }, - }; - - if (Boom.isBoom(error)) { - opts.statusCode = error.output.statusCode; - } - - if (error instanceof RequestAbortedError) { - opts.statusCode = 499; - opts.body.message = 'Client closed request'; - } - - return response.custom(opts); - } - } - ); - }); - }, - }; - - return api; -} - -function validateParams( - request: KibanaRequest, - params: RouteParamsRT | undefined -) { - const paramsRt = params ? merge([params, inspectRt]) : inspectRt; - const paramMap = pickBy( - { - path: request.params, - body: request.body, - query: { - _inspect: 'false', - // @ts-ignore - ...request.query, - }, - }, - isNotEmpty - ); - - const result = strictKeysRt(paramsRt).decode(paramMap); - - if (isLeft(result)) { - throw Boom.badRequest(PathReporter.report(result)[0]); - } - - // Only return values for parameters that have runtime types, - // but always include query as _inspect is always set even if - // it's not defined in the route. - return mergeLodash( - { query: { _inspect: false } }, - pickBy(result.right, isNotEmpty) - ); -} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts deleted file mode 100644 index 5b74aa4347f14..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ /dev/null @@ -1,230 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - staticIndexPatternRoute, - dynamicIndexPatternRoute, - apmIndexPatternTitleRoute, -} from './index_pattern'; -import { createApi } from './create_api'; -import { environmentsRoute } from './environments'; -import { - errorDistributionRoute, - errorGroupsRoute, - errorsRoute, -} from './errors'; -import { - serviceAgentNameRoute, - serviceTransactionTypesRoute, - servicesRoute, - serviceNodeMetadataRoute, - serviceAnnotationsRoute, - serviceAnnotationsCreateRoute, - serviceErrorGroupsPrimaryStatisticsRoute, - serviceErrorGroupsComparisonStatisticsRoute, - serviceThroughputRoute, - serviceDependenciesRoute, - serviceMetadataDetailsRoute, - serviceMetadataIconsRoute, - serviceInstancesPrimaryStatisticsRoute, - serviceInstancesComparisonStatisticsRoute, - serviceProfilingStatisticsRoute, - serviceProfilingTimelineRoute, -} from './services'; -import { - agentConfigurationRoute, - getSingleAgentConfigurationRoute, - agentConfigurationSearchRoute, - deleteAgentConfigurationRoute, - listAgentConfigurationEnvironmentsRoute, - listAgentConfigurationServicesRoute, - createOrUpdateAgentConfigurationRoute, - agentConfigurationAgentNameRoute, -} from './settings/agent_configuration'; -import { - apmIndexSettingsRoute, - apmIndicesRoute, - saveApmIndicesRoute, -} from './settings/apm_indices'; -import { metricsChartsRoute } from './metrics'; -import { serviceNodesRoute } from './service_nodes'; -import { - tracesRoute, - tracesByIdRoute, - rootTransactionByTraceIdRoute, -} from './traces'; -import { - correlationsLatencyDistributionRoute, - correlationsForSlowTransactionsRoute, - correlationsErrorDistributionRoute, - correlationsForFailedTransactionsRoute, -} from './correlations'; -import { - transactionChartsBreakdownRoute, - transactionChartsDistributionRoute, - transactionChartsErrorRateRoute, - transactionGroupsRoute, - transactionGroupsPrimaryStatisticsRoute, - transactionLatencyChartsRoute, - transactionThroughputChartsRoute, - transactionGroupsComparisonStatisticsRoute, -} from './transactions'; -import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; -import { - createCustomLinkRoute, - updateCustomLinkRoute, - deleteCustomLinkRoute, - listCustomLinksRoute, - customLinkTransactionRoute, -} from './settings/custom_link'; -import { - observabilityOverviewHasDataRoute, - observabilityOverviewRoute, -} from './observability_overview'; -import { - anomalyDetectionJobsRoute, - createAnomalyDetectionJobsRoute, - anomalyDetectionEnvironmentsRoute, -} from './settings/anomaly_detection'; -import { - rumHasDataRoute, - rumClientMetricsRoute, - rumJSErrors, - rumLongTaskMetrics, - rumOverviewLocalFiltersRoute, - rumPageLoadDistBreakdownRoute, - rumPageLoadDistributionRoute, - rumPageViewsTrendRoute, - rumServicesRoute, - rumUrlSearch, - rumVisitorsBreakdownRoute, - rumWebCoreVitals, -} from './rum_client'; -import { - transactionErrorRateChartPreview, - transactionErrorCountChartPreview, - transactionDurationChartPreview, -} from './alerts/chart_preview'; - -const createApmApi = () => { - const api = createApi() - // index pattern - .add(staticIndexPatternRoute) - .add(dynamicIndexPatternRoute) - .add(apmIndexPatternTitleRoute) - - // Environments - .add(environmentsRoute) - - // Errors - .add(errorDistributionRoute) - .add(errorGroupsRoute) - .add(errorsRoute) - - // Services - .add(serviceAgentNameRoute) - .add(serviceTransactionTypesRoute) - .add(servicesRoute) - .add(serviceNodeMetadataRoute) - .add(serviceAnnotationsRoute) - .add(serviceAnnotationsCreateRoute) - .add(serviceErrorGroupsPrimaryStatisticsRoute) - .add(serviceThroughputRoute) - .add(serviceDependenciesRoute) - .add(serviceMetadataDetailsRoute) - .add(serviceMetadataIconsRoute) - .add(serviceInstancesPrimaryStatisticsRoute) - .add(serviceInstancesComparisonStatisticsRoute) - .add(serviceErrorGroupsComparisonStatisticsRoute) - .add(serviceProfilingTimelineRoute) - .add(serviceProfilingStatisticsRoute) - - // Agent configuration - .add(getSingleAgentConfigurationRoute) - .add(agentConfigurationAgentNameRoute) - .add(agentConfigurationRoute) - .add(agentConfigurationSearchRoute) - .add(deleteAgentConfigurationRoute) - .add(listAgentConfigurationEnvironmentsRoute) - .add(listAgentConfigurationServicesRoute) - .add(createOrUpdateAgentConfigurationRoute) - - // Correlations - .add(correlationsLatencyDistributionRoute) - .add(correlationsForSlowTransactionsRoute) - .add(correlationsErrorDistributionRoute) - .add(correlationsForFailedTransactionsRoute) - - // APM indices - .add(apmIndexSettingsRoute) - .add(apmIndicesRoute) - .add(saveApmIndicesRoute) - - // Metrics - .add(metricsChartsRoute) - .add(serviceNodesRoute) - - // Traces - .add(tracesRoute) - .add(tracesByIdRoute) - .add(rootTransactionByTraceIdRoute) - - // Transactions - .add(transactionChartsBreakdownRoute) - .add(transactionChartsDistributionRoute) - .add(transactionChartsErrorRateRoute) - .add(transactionGroupsRoute) - .add(transactionGroupsPrimaryStatisticsRoute) - .add(transactionLatencyChartsRoute) - .add(transactionThroughputChartsRoute) - .add(transactionGroupsComparisonStatisticsRoute) - - // Service map - .add(serviceMapRoute) - .add(serviceMapServiceNodeRoute) - - // Custom links - .add(createCustomLinkRoute) - .add(updateCustomLinkRoute) - .add(deleteCustomLinkRoute) - .add(listCustomLinksRoute) - .add(customLinkTransactionRoute) - - // Observability dashboard - .add(observabilityOverviewHasDataRoute) - .add(observabilityOverviewRoute) - - // Anomaly detection - .add(anomalyDetectionJobsRoute) - .add(createAnomalyDetectionJobsRoute) - .add(anomalyDetectionEnvironmentsRoute) - - // User Experience app api routes - .add(rumOverviewLocalFiltersRoute) - .add(rumPageViewsTrendRoute) - .add(rumPageLoadDistributionRoute) - .add(rumPageLoadDistBreakdownRoute) - .add(rumClientMetricsRoute) - .add(rumServicesRoute) - .add(rumVisitorsBreakdownRoute) - .add(rumWebCoreVitals) - .add(rumJSErrors) - .add(rumUrlSearch) - .add(rumLongTaskMetrics) - .add(rumHasDataRoute) - - // Alerting - .add(transactionErrorCountChartPreview) - .add(transactionDurationChartPreview) - .add(transactionErrorRateChartPreview); - - return api; -}; - -export type APMAPI = ReturnType; - -export { createApmApi }; diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route.ts new file mode 100644 index 0000000000000..86330a87a8c55 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createServerRouteFactory } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export const createApmServerRoute = createServerRouteFactory< + APMRouteHandlerResources, + APMRouteCreateOptions +>(); diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts new file mode 100644 index 0000000000000..b7cbe890c57db --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createServerRouteRepository } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export function createApmServerRouteRepository() { + return createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); +} diff --git a/x-pack/plugins/apm/server/routes/create_route.ts b/x-pack/plugins/apm/server/routes/create_route.ts deleted file mode 100644 index d74aac0992eb4..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_route.ts +++ /dev/null @@ -1,29 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from 'src/core/server'; -import { HandlerReturn, Route, RouteParamsRT } from './typings'; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: Route -): Route; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: (core: CoreSetup) => Route -): (core: CoreSetup) => Route; - -export function createRoute(routeOrFactoryFn: Function | object) { - return routeOrFactoryFn; -} diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts index 4aa7d7e6d412f..e06fbdf7fb6d4 100644 --- a/x-pack/plugins/apm/server/routes/environments.ts +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -9,10 +9,11 @@ import * as t from 'io-ts'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getEnvironments } from '../lib/environments/get_environments'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const environmentsRoute = createRoute({ +const environmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/environments', params: t.type({ query: t.intersection([ @@ -23,9 +24,10 @@ export const environmentsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -39,3 +41,7 @@ export const environmentsRoute = createRoute({ return { environments }; }, }); + +export const environmentsRouteRepository = createApmServerRouteRepository().add( + environmentsRoute +); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index f69d3fc9631d1..d6bb1d4bcbaae 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -6,14 +6,15 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { getErrorDistribution } from '../lib/errors/distribution/get_distribution'; import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const errorsRoute = createRoute({ +const errorsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const errorsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { environment, kuery, sortField, sortDirection } = params.query; @@ -49,7 +50,7 @@ export const errorsRoute = createRoute({ }, }); -export const errorGroupsRoute = createRoute({ +const errorGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/{groupId}', params: t.type({ path: t.type({ @@ -59,10 +60,11 @@ export const errorGroupsRoute = createRoute({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, groupId } = context.params.path; - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); + const { serviceName, groupId } = params.path; + const { environment, kuery } = params.query; return getErrorGroupSample({ environment, @@ -74,7 +76,7 @@ export const errorGroupsRoute = createRoute({ }, }); -export const errorDistributionRoute = createRoute({ +const errorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/distribution', params: t.type({ path: t.type({ @@ -90,9 +92,9 @@ export const errorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { environment, kuery, groupId } = params.query; return getErrorDistribution({ @@ -104,3 +106,8 @@ export const errorDistributionRoute = createRoute({ }); }, }); + +export const errorsRouteRepository = createApmServerRouteRepository() + .add(errorsRoute) + .add(errorGroupsRoute) + .add(errorDistributionRoute); diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts new file mode 100644 index 0000000000000..c151752b4b6e0 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + ServerRouteRepository, + ReturnOf, + EndpointOf, +} from '@kbn/server-route-repository'; +import { PickByValue } from 'utility-types'; +import { alertsChartPreviewRouteRepository } from './alerts/chart_preview'; +import { correlationsRouteRepository } from './correlations'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { environmentsRouteRepository } from './environments'; +import { errorsRouteRepository } from './errors'; +import { indexPatternRouteRepository } from './index_pattern'; +import { metricsRouteRepository } from './metrics'; +import { observabilityOverviewRouteRepository } from './observability_overview'; +import { rumRouteRepository } from './rum_client'; +import { serviceRouteRepository } from './services'; +import { serviceMapRouteRepository } from './service_map'; +import { serviceNodeRouteRepository } from './service_nodes'; +import { agentConfigurationRouteRepository } from './settings/agent_configuration'; +import { anomalyDetectionRouteRepository } from './settings/anomaly_detection'; +import { apmIndicesRouteRepository } from './settings/apm_indices'; +import { customLinkRouteRepository } from './settings/custom_link'; +import { traceRouteRepository } from './traces'; +import { transactionRouteRepository } from './transactions'; +import { APMRouteHandlerResources } from './typings'; + +const getTypedGlobalApmServerRouteRepository = () => { + const repository = createApmServerRouteRepository() + .merge(indexPatternRouteRepository) + .merge(environmentsRouteRepository) + .merge(errorsRouteRepository) + .merge(metricsRouteRepository) + .merge(observabilityOverviewRouteRepository) + .merge(rumRouteRepository) + .merge(serviceMapRouteRepository) + .merge(serviceNodeRouteRepository) + .merge(serviceRouteRepository) + .merge(traceRouteRepository) + .merge(transactionRouteRepository) + .merge(alertsChartPreviewRouteRepository) + .merge(correlationsRouteRepository) + .merge(agentConfigurationRouteRepository) + .merge(anomalyDetectionRouteRepository) + .merge(apmIndicesRouteRepository) + .merge(customLinkRouteRepository); + + return repository; +}; + +const getGlobalApmServerRouteRepository = () => { + return getTypedGlobalApmServerRouteRepository() as ServerRouteRepository; +}; + +export type APMServerRouteRepository = ReturnType< + typeof getTypedGlobalApmServerRouteRepository +>; + +// Ensure no APIs return arrays (or, by proxy, the any type), +// to guarantee compatibility with _inspect. + +type CompositeEndpoint = EndpointOf; + +type EndpointReturnTypes = { + [Endpoint in CompositeEndpoint]: ReturnOf; +}; + +type ArrayLikeReturnTypes = PickByValue; + +type ViolatingEndpoints = keyof ArrayLikeReturnTypes; + +function assertType() {} + +// if any endpoint has an array-like return type, the assertion below will fail +assertType(); + +export { getGlobalApmServerRouteRepository }; diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 3b800c23135ce..aa70cde4f96ae 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -6,49 +6,67 @@ */ import { createStaticIndexPattern } from '../lib/index_pattern/create_static_index_pattern'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; import { getApmIndexPatternTitle } from '../lib/index_pattern/get_apm_index_pattern_title'; import { getDynamicIndexPattern } from '../lib/index_pattern/get_dynamic_index_pattern'; +import { createApmServerRoute } from './create_apm_server_route'; -export const staticIndexPatternRoute = createRoute((core) => ({ +const staticIndexPatternRoute = createApmServerRoute({ endpoint: 'POST /api/apm/index_pattern/static', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { + request, + core, + plugins: { spaces }, + config, + } = resources; + const [setup, savedObjectsClient] = await Promise.all([ - setupRequest(context, request), - getInternalSavedObjectsClient(core), + setupRequest(resources), + core + .start() + .then((coreStart) => coreStart.savedObjects.createInternalRepository()), ]); - const spaceId = context.plugins.spaces?.spacesService.getSpaceId(request); + const spaceId = spaces?.setup.spacesService.getSpaceId(request); const didCreateIndexPattern = await createStaticIndexPattern( setup, - context, + config, savedObjectsClient, spaceId ); return { created: didCreateIndexPattern }; }, -})); +}); -export const dynamicIndexPatternRoute = createRoute({ +const dynamicIndexPatternRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/dynamic', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const dynamicIndexPattern = await getDynamicIndexPattern({ context }); + handler: async ({ context, config, logger }) => { + const dynamicIndexPattern = await getDynamicIndexPattern({ + context, + config, + logger, + }); return { dynamicIndexPattern }; }, }); -export const apmIndexPatternTitleRoute = createRoute({ +const indexPatternTitleRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/title', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async ({ config }) => { return { - indexPatternTitle: getApmIndexPatternTitle(context), + indexPatternTitle: getApmIndexPatternTitle(config), }; }, }); + +export const indexPatternRouteRepository = createApmServerRouteRepository() + .add(staticIndexPatternRoute) + .add(dynamicIndexPatternRoute) + .add(indexPatternTitleRoute); diff --git a/x-pack/plugins/apm/server/routes/metrics.ts b/x-pack/plugins/apm/server/routes/metrics.ts index c7e82e13d07b8..9fa2346eb72fb 100644 --- a/x-pack/plugins/apm/server/routes/metrics.ts +++ b/x-pack/plugins/apm/server/routes/metrics.ts @@ -8,10 +8,11 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; -export const metricsChartsRoute = createRoute({ +const metricsChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metrics/charts', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const metricsChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { agentName, environment, kuery, serviceNodeName } = params.query; return await getMetricsChartDataByAgent({ @@ -45,3 +46,7 @@ export const metricsChartsRoute = createRoute({ }); }, }); + +export const metricsRouteRepository = createApmServerRouteRepository().add( + metricsChartsRoute +); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 1aac2c09d01c5..d459570cf7337 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -10,30 +10,32 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceCount } from '../lib/observability_overview/get_service_count'; import { getTransactionsPerMinute } from '../lib/observability_overview/get_transactions_per_minute'; import { getHasData } from '../lib/observability_overview/has_data'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { withApmSpan } from '../utils/with_apm_span'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; -export const observabilityOverviewHasDataRoute = createRoute({ +const observabilityOverviewHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_data', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const res = await getHasData({ setup }); return { hasData: res }; }, }); -export const observabilityOverviewRoute = createRoute({ +const observabilityOverviewRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview', params: t.type({ query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { bucketSize } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { bucketSize } = resources.params.query; + const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -54,3 +56,7 @@ export const observabilityOverviewRoute = createRoute({ }); }, }); + +export const observabilityOverviewRouteRepository = createApmServerRouteRepository() + .add(observabilityOverviewRoute) + .add(observabilityOverviewHasDataRoute); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.test.ts b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts new file mode 100644 index 0000000000000..82b73d46da5c1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts @@ -0,0 +1,507 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { jsonRt } from '@kbn/io-ts-utils'; +import { createServerRouteRepository } from '@kbn/server-route-repository'; +import { ServerRoute } from '@kbn/server-route-repository/target/typings'; +import * as t from 'io-ts'; +import { CoreSetup, Logger } from 'src/core/server'; +import { APMConfig } from '../..'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from '../typings'; +import { registerRoutes } from './index'; + +type RegisterRouteDependencies = Parameters[0]; + +const getRegisterRouteDependencies = () => { + const get = jest.fn(); + const post = jest.fn(); + const put = jest.fn(); + const createRouter = jest.fn().mockReturnValue({ + get, + post, + put, + }); + + const coreSetup = ({ + http: { + createRouter, + }, + } as unknown) as CoreSetup; + + const logger = ({ + error: jest.fn(), + } as unknown) as Logger; + + return { + mocks: { + get, + post, + put, + createRouter, + coreSetup, + logger, + }, + dependencies: ({ + core: { + setup: coreSetup, + }, + logger, + config: {} as APMConfig, + plugins: {}, + } as unknown) as RegisterRouteDependencies, + }; +}; + +const getRepository = () => + createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); + +const initApi = ( + routes: Array< + ServerRoute< + any, + t.Any, + APMRouteHandlerResources, + any, + APMRouteCreateOptions + > + > +) => { + const { mocks, dependencies } = getRegisterRouteDependencies(); + + let repository = getRepository(); + + routes.forEach((route) => { + repository = repository.add(route); + }); + + registerRoutes({ + ...dependencies, + repository, + }); + + const responseMock = { + ok: jest.fn(), + custom: jest.fn(), + }; + + const simulateRequest = (request: { + method: 'get' | 'post' | 'put'; + pathname: string; + params?: Record; + body?: unknown; + query?: Record; + }) => { + const [, registeredRouteHandler] = + mocks[request.method].mock.calls.find((call) => { + return call[0].path === request.pathname; + }) ?? []; + + const result = registeredRouteHandler( + {}, + { + params: {}, + query: {}, + body: null, + ...request, + }, + responseMock + ); + + return result; + }; + + return { + simulateRequest, + mocks: { + ...mocks, + response: responseMock, + }, + }; +}; + +describe('createApi', () => { + it('registers a route with the server', () => { + const { + mocks: { createRouter, get, post, put }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'POST /bar', + params: t.type({ + body: t.string, + }), + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'PUT /baz', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + { + endpoint: 'GET /qux', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + ]); + + expect(createRouter).toHaveBeenCalledTimes(1); + + expect(get).toHaveBeenCalledTimes(2); + expect(post).toHaveBeenCalledTimes(1); + expect(put).toHaveBeenCalledTimes(1); + + expect(get.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/foo', + validate: expect.anything(), + }); + + expect(get.mock.calls[1][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/qux', + validate: expect.anything(), + }); + + expect(post.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/bar', + validate: expect.anything(), + }); + + expect(put.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/baz', + validate: expect.anything(), + }); + }); + + describe('when validating', () => { + describe('_inspect', () => { + it('allows _inspect=true', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true' }, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: true } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledWith({ + body: { _inspect: [] }, + }); + }); + + it('rejects _inspect=1', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 1 }, + }); + + // responds with error handler + expect(response.ok).not.toHaveBeenCalled(); + expect(response.custom).toHaveBeenCalledWith({ + body: { + attributes: { _inspect: [] }, + message: + 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', + }, + statusCode: 400, + }); + }); + + it('allows omitting _inspect', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: handlerMock }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: {}, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: false } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledWith({ body: {} }); + }); + }); + + it('throws if unknown parameters are provided', async () => { + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: jest.fn() }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true', extra: '' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: { foo: 'bar' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates path parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: [] }, + params: t.type({ + path: t.type({ + foo: t.string, + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledTimes(1); + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + path: { + foo: 'bar', + }, + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + bar: 'foo', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 9, + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + extra: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates body parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + body: t.string, + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: '', + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + body: '', + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: null, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + + it('validates query parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + query: t.type({ + bar: t.string, + filterNames: jsonRt.pipe(t.array(t.string)), + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + _inspect: 'true', + filterNames: JSON.stringify(['hostName', 'agentName']), + }, + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + query: { + bar: '', + _inspect: true, + filterNames: ['hostName', 'agentName'], + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + foo: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.ts b/x-pack/plugins/apm/server/routes/register_routes/index.ts new file mode 100644 index 0000000000000..3a88a496b923f --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import * as t from 'io-ts'; +import { KibanaRequest, RouteRegistrar } from 'src/core/server'; +import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import agent from 'elastic-apm-node'; +import { ServerRouteRepository } from '@kbn/server-route-repository'; +import { merge } from 'lodash'; +import { + decodeRequestParams, + parseEndpoint, + routeValidationObject, +} from '@kbn/server-route-repository'; +import { mergeRt, jsonRt } from '@kbn/io-ts-utils'; +import { pickKeys } from '../../../common/utils/pick_keys'; +import { APMRouteHandlerResources, InspectResponse } from '../typings'; +import type { ApmPluginRequestHandlerContext } from '../typings'; + +const inspectRt = t.exact( + t.partial({ + query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), + }) +); + +export const inspectableEsQueriesMap = new WeakMap< + KibanaRequest, + InspectResponse +>(); + +export function registerRoutes({ + core, + repository, + plugins, + logger, + config, +}: { + core: APMRouteHandlerResources['core']; + plugins: APMRouteHandlerResources['plugins']; + logger: APMRouteHandlerResources['logger']; + repository: ServerRouteRepository; + config: APMRouteHandlerResources['config']; +}) { + const routes = repository.getRoutes(); + + const router = core.setup.http.createRouter(); + + routes.forEach((route) => { + const { params, endpoint, options, handler } = route; + + const { method, pathname } = parseEndpoint(endpoint); + + (router[method] as RouteRegistrar< + typeof method, + ApmPluginRequestHandlerContext + >)( + { + path: pathname, + options, + validate: routeValidationObject, + }, + async (context, request, response) => { + if (agent.isStarted()) { + agent.addLabels({ + plugin: 'apm', + }); + } + + // init debug queries + inspectableEsQueriesMap.set(request, []); + + try { + const runtimeType = params ? mergeRt(params, inspectRt) : inspectRt; + + const validatedParams = decodeRequestParams( + pickKeys(request, 'params', 'body', 'query'), + runtimeType + ); + + const data: Record | undefined | null = (await handler({ + request, + context, + config, + logger, + core, + plugins, + params: merge( + { + query: { + _inspect: false, + }, + }, + validatedParams + ), + })) as any; + + if (Array.isArray(data)) { + throw new Error('Return type cannot be an array'); + } + + const body = validatedParams.query?._inspect + ? { + ...data, + _inspect: inspectableEsQueriesMap.get(request), + } + : { ...data }; + + // cleanup + inspectableEsQueriesMap.delete(request); + + return response.ok({ body }); + } catch (error) { + logger.error(error); + const opts = { + statusCode: 500, + body: { + message: error.message, + attributes: { + _inspect: inspectableEsQueriesMap.get(request), + }, + }, + }; + + if (Boom.isBoom(error)) { + opts.statusCode = error.output.statusCode; + } + + if (error instanceof RequestAbortedError) { + opts.statusCode = 499; + opts.body.message = 'Client closed request'; + } + + return response.custom(opts); + } + } + ); + }); +} diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 3156acb469a72..d7f91adc0d683 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { jsonRt } from '../../common/runtime_types/json_rt'; +import { jsonRt } from '@kbn/io-ts-utils'; import { LocalUIFilterName } from '../../common/ui_filter'; import { Setup, @@ -28,9 +28,10 @@ import { getLocalUIFilters } from '../lib/rum_client/ui_filters/local_ui_filters import { localUIFilterNames } from '../lib/rum_client/ui_filters/local_ui_filters/config'; import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; import { Projection } from '../projections/typings'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { rangeRt } from './default_api_types'; -import { APMRequestHandlerContext } from './typings'; +import { APMRouteHandlerResources } from './typings'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -45,18 +46,18 @@ const uxQueryRt = t.intersection([ t.partial({ urlQuery: t.string, percentile: t.string }), ]); -export const rumClientMetricsRoute = createRoute({ +const rumClientMetricsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum/client-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getClientMetrics({ setup, @@ -66,18 +67,18 @@ export const rumClientMetricsRoute = createRoute({ }, }); -export const rumPageLoadDistributionRoute = createRoute({ +const rumPageLoadDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution', params: t.type({ query: t.intersection([uxQueryRt, percentileRangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistribution = await getPageLoadDistribution({ setup, @@ -90,7 +91,7 @@ export const rumPageLoadDistributionRoute = createRoute({ }, }); -export const rumPageLoadDistBreakdownRoute = createRoute({ +const rumPageLoadDistBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution/breakdown', params: t.type({ query: t.intersection([ @@ -100,12 +101,12 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, breakdown, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistBreakdown = await getPageLoadDistBreakdown({ setup, @@ -119,18 +120,18 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ }, }); -export const rumPageViewsTrendRoute = createRoute({ +const rumPageViewsTrendRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-view-trends', params: t.type({ query: t.intersection([uxQueryRt, t.partial({ breakdowns: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { breakdowns, urlQuery }, - } = context.params; + } = resources.params; return getPageViewTrends({ setup, @@ -140,32 +141,32 @@ export const rumPageViewsTrendRoute = createRoute({ }, }); -export const rumServicesRoute = createRoute({ +const rumServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/services', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const rumServices = await getRumServices({ setup }); return { rumServices }; }, }); -export const rumVisitorsBreakdownRoute = createRoute({ +const rumVisitorsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/visitor-breakdown', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery }, - } = context.params; + } = resources.params; return getVisitorBreakdown({ setup, @@ -174,18 +175,18 @@ export const rumVisitorsBreakdownRoute = createRoute({ }, }); -export const rumWebCoreVitals = createRoute({ +const rumWebCoreVitals = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/web-core-vitals', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getWebCoreVitals({ setup, @@ -195,18 +196,18 @@ export const rumWebCoreVitals = createRoute({ }, }); -export const rumLongTaskMetrics = createRoute({ +const rumLongTaskMetrics = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/long-task-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getLongTaskMetrics({ setup, @@ -216,24 +217,24 @@ export const rumLongTaskMetrics = createRoute({ }, }); -export const rumUrlSearch = createRoute({ +const rumUrlSearch = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/url-search', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getUrlSearch({ setup, urlQuery, percentile: Number(percentile) }); }, }); -export const rumJSErrors = createRoute({ +const rumJSErrors = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/js-errors', params: t.type({ query: t.intersection([ @@ -244,12 +245,12 @@ export const rumJSErrors = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { pageSize, pageIndex, urlQuery }, - } = context.params; + } = resources.params; return getJSErrors({ setup, @@ -260,14 +261,14 @@ export const rumJSErrors = createRoute({ }, }); -export const rumHasDataRoute = createRoute({ +const rumHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); return await hasRumData({ setup }); }, }); @@ -309,21 +310,22 @@ function createLocalFiltersRoute< >; queryRt: TQueryRT; }) { - return createRoute({ + return createApmServerRoute({ endpoint, params: t.type({ query: t.intersection([localUiBaseQueryRt, queryRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { uiFilters } = setup; - const { query } = context.params; + + const { query } = resources.params; const { filterNames } = query; const projection = await getProjection({ query, - context, + resources, setup, }); @@ -339,7 +341,7 @@ function createLocalFiltersRoute< }); } -export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ +const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ endpoint: 'GET /api/apm/rum/local_filters', getProjection: async ({ setup }) => { return getRumPageLoadTransactionsProjection({ @@ -357,9 +359,23 @@ type GetProjection< > = ({ query, setup, - context, + resources, }: { query: t.TypeOf; setup: Setup & SetupTimeRange; - context: APMRequestHandlerContext; + resources: APMRouteHandlerResources; }) => Promise | TProjection; + +export const rumRouteRepository = createApmServerRouteRepository() + .add(rumClientMetricsRoute) + .add(rumPageLoadDistributionRoute) + .add(rumPageLoadDistBreakdownRoute) + .add(rumPageViewsTrendRoute) + .add(rumServicesRoute) + .add(rumVisitorsBreakdownRoute) + .add(rumWebCoreVitals) + .add(rumLongTaskMetrics) + .add(rumUrlSearch) + .add(rumJSErrors) + .add(rumHasDataRoute) + .add(rumOverviewLocalFiltersRoute); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 33943d6e05d01..267479de4c102 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -11,13 +11,14 @@ import { invalidLicenseMessage } from '../../common/service_map'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, rangeRt } from './default_api_types'; import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { isActivePlatinumLicense } from '../../common/license_check'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const serviceMapRoute = createRoute({ +const serviceMapRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map', params: t.type({ query: t.intersection([ @@ -29,8 +30,9 @@ export const serviceMapRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params, logger } = resources; + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { @@ -42,11 +44,10 @@ export const serviceMapRoute = createRoute({ featureName: 'serviceMaps', }); - const logger = context.logger; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { query: { serviceName, environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -61,7 +62,7 @@ export const serviceMapRoute = createRoute({ }, }); -export const serviceMapServiceNodeRoute = createRoute({ +const serviceMapServiceNodeRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map/service/{serviceName}', params: t.type({ path: t.type({ @@ -70,19 +71,21 @@ export const serviceMapServiceNodeRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params } = resources; + + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { path: { serviceName }, query: { environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +99,7 @@ export const serviceMapServiceNodeRoute = createRoute({ }); }, }); + +export const serviceMapRouteRepository = createApmServerRouteRepository() + .add(serviceMapRoute) + .add(serviceMapServiceNodeRoute); diff --git a/x-pack/plugins/apm/server/routes/service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes.ts index e9060688c63a6..a2eb12662cbca 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes.ts @@ -6,12 +6,13 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceNodes } from '../lib/service_nodes'; import { rangeRt, kueryRt } from './default_api_types'; -export const serviceNodesRoute = createRoute({ +const serviceNodesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/serviceNodes', params: t.type({ path: t.type({ @@ -20,9 +21,9 @@ export const serviceNodesRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { kuery } = params.query; @@ -30,3 +31,7 @@ export const serviceNodesRoute = createRoute({ return { serviceNodes }; }, }); + +export const serviceNodeRouteRepository = createApmServerRouteRepository().add( + serviceNodesRoute +); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index b4d25ca8b2a06..800a5bdcc5d5f 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -6,15 +6,12 @@ */ import Boom from '@hapi/boom'; +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { uniq } from 'lodash'; -import { - LatencyAggregationType, - latencyAggregationTypeRt, -} from '../../common/latency_aggregation_types'; +import { latencyAggregationTypeRt } from '../../common/latency_aggregation_types'; import { ProfilingValueType } from '../../common/profiling'; import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -35,7 +32,8 @@ import { getServiceProfilingStatistics } from '../lib/services/profiling/get_ser import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; import { withApmSpan } from '../utils/with_apm_span'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -43,15 +41,16 @@ import { rangeRt, } from './default_api_types'; -export const servicesRoute = createRoute({ +const servicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -61,21 +60,22 @@ export const servicesRoute = createRoute({ kuery, setup, searchAggregatedTransactions, - logger: context.logger, + logger, }); }, }); -export const serviceMetadataDetailsRoute = createRoute({ +const serviceMetadataDetailsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -89,16 +89,17 @@ export const serviceMetadataDetailsRoute = createRoute({ }, }); -export const serviceMetadataIconsRoute = createRoute({ +const serviceMetadataIconsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -112,7 +113,7 @@ export const serviceMetadataIconsRoute = createRoute({ }, }); -export const serviceAgentNameRoute = createRoute({ +const serviceAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/agent_name', params: t.type({ path: t.type({ @@ -121,9 +122,10 @@ export const serviceAgentNameRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -136,7 +138,7 @@ export const serviceAgentNameRoute = createRoute({ }, }); -export const serviceTransactionTypesRoute = createRoute({ +const serviceTransactionTypesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction_types', params: t.type({ path: t.type({ @@ -145,9 +147,11 @@ export const serviceTransactionTypesRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + return getServiceTransactionTypes({ serviceName, setup, @@ -158,7 +162,7 @@ export const serviceTransactionTypesRoute = createRoute({ }, }); -export const serviceNodeMetadataRoute = createRoute({ +const serviceNodeMetadataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', params: t.type({ @@ -169,10 +173,11 @@ export const serviceNodeMetadataRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, serviceNodeName } = context.params.path; - const { kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName, serviceNodeName } = params.path; + const { kuery } = params.query; return getServiceNodeMetadata({ kuery, @@ -183,7 +188,7 @@ export const serviceNodeMetadataRoute = createRoute({ }, }); -export const serviceAnnotationsRoute = createRoute({ +const serviceAnnotationsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', params: t.type({ path: t.type({ @@ -192,12 +197,13 @@ export const serviceAnnotationsRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, plugins, context, request, logger } = resources; + const { serviceName } = params.path; + const { environment } = params.query; - const { observability } = context.plugins; + const { observability } = plugins; const [ annotationsClient, @@ -205,7 +211,7 @@ export const serviceAnnotationsRoute = createRoute({ ] = await Promise.all([ observability ? withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined, getSearchAggregatedTransactions(setup), @@ -218,12 +224,12 @@ export const serviceAnnotationsRoute = createRoute({ serviceName, annotationsClient, client: context.core.elasticsearch.client.asCurrentUser, - logger: context.logger, + logger, }); }, }); -export const serviceAnnotationsCreateRoute = createRoute({ +const serviceAnnotationsCreateRoute = createApmServerRoute({ endpoint: 'POST /api/apm/services/{serviceName}/annotation', options: { tags: ['access:apm', 'access:apm_write'], @@ -250,12 +256,17 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), ]), }), - handler: async ({ request, context }) => { - const { observability } = context.plugins; + handler: async (resources) => { + const { + request, + context, + plugins: { observability }, + params, + } = resources; const annotationsClient = observability ? await withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined; @@ -263,7 +274,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ throw Boom.notFound(); } - const { body, path } = context.params; + const { body, path } = params; return withApmSpan('create_annotation', () => annotationsClient.create({ @@ -283,7 +294,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }, }); -export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ +const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics', params: t.type({ @@ -300,13 +311,14 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { kuery, transactionType, environment }, - } = context.params; + } = params; return getServiceErrorGroupPrimaryStatistics({ kuery, serviceName, @@ -317,7 +329,7 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ +const serviceErrorGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics', params: t.type({ @@ -337,8 +349,9 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, @@ -351,7 +364,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return getServiceErrorGroupPeriods({ environment, @@ -367,7 +380,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ }, }); -export const serviceThroughputRoute = createRoute({ +const serviceThroughputRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/throughput', params: t.type({ path: t.type({ @@ -382,16 +395,17 @@ export const serviceThroughputRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, transactionType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -432,7 +446,7 @@ export const serviceThroughputRoute = createRoute({ }, }); -export const serviceInstancesPrimaryStatisticsRoute = createRoute({ +const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', params: t.type({ @@ -450,12 +464,16 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -479,7 +497,7 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceInstancesComparisonStatisticsRoute = createRoute({ +const serviceInstancesComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', params: t.type({ @@ -500,9 +518,10 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -511,9 +530,8 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ comparisonEnd, serviceNodeIds, numBuckets, - } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -535,7 +553,7 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ }, }); -export const serviceDependenciesRoute = createRoute({ +const serviceDependenciesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/dependencies', params: t.type({ path: t.type({ @@ -552,11 +570,11 @@ export const serviceDependenciesRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - - const { serviceName } = context.params.path; - const { environment, numBuckets } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, numBuckets } = params.query; const serviceDependencies = await getServiceDependencies({ serviceName, @@ -569,7 +587,7 @@ export const serviceDependenciesRoute = createRoute({ }, }); -export const serviceProfilingTimelineRoute = createRoute({ +const serviceProfilingTimelineRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', params: t.type({ path: t.type({ @@ -580,13 +598,13 @@ export const serviceProfilingTimelineRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { environment, kuery }, - } = context.params; + } = params; const profilingTimeline = await getServiceProfilingTimeline({ kuery, @@ -599,7 +617,7 @@ export const serviceProfilingTimelineRoute = createRoute({ }, }); -export const serviceProfilingStatisticsRoute = createRoute({ +const serviceProfilingStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', params: t.type({ path: t.type({ @@ -625,13 +643,15 @@ export const serviceProfilingStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params, logger } = resources; const { path: { serviceName }, query: { environment, kuery, valueType }, - } = context.params; + } = params; return getServiceProfilingStatistics({ kuery, @@ -639,7 +659,25 @@ export const serviceProfilingStatisticsRoute = createRoute({ environment, valueType, setup, - logger: context.logger, + logger, }); }, }); + +export const serviceRouteRepository = createApmServerRouteRepository() + .add(servicesRoute) + .add(serviceMetadataDetailsRoute) + .add(serviceMetadataIconsRoute) + .add(serviceAgentNameRoute) + .add(serviceTransactionTypesRoute) + .add(serviceNodeMetadataRoute) + .add(serviceAnnotationsRoute) + .add(serviceAnnotationsCreateRoute) + .add(serviceErrorGroupsPrimaryStatisticsRoute) + .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceThroughputRoute) + .add(serviceInstancesPrimaryStatisticsRoute) + .add(serviceInstancesComparisonStatisticsRoute) + .add(serviceDependenciesRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 31e8d6cc1e9f0..111e0a18c8608 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -16,7 +16,7 @@ import { findExactConfiguration } from '../../lib/settings/agent_configuration/f import { listConfigurations } from '../../lib/settings/agent_configuration/list_configurations'; import { getEnvironments } from '../../lib/settings/agent_configuration/get_environments'; import { deleteConfiguration } from '../../lib/settings/agent_configuration/delete_configuration'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAgentNameByService } from '../../lib/settings/agent_configuration/get_agent_name_by_service'; import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_applied_by_agent'; import { @@ -24,34 +24,37 @@ import { agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get list of configurations -export const agentConfigurationRoute = createRoute({ +const agentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const configurations = await listConfigurations({ setup }); return { configurations }; }, }); // get a single configuration -export const getSingleAgentConfigurationRoute = createRoute({ +const getSingleAgentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/view', params: t.partial({ query: serviceRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { name, environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { name, environment } = params.query; const service = { name, environment }; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); @@ -63,7 +66,7 @@ export const getSingleAgentConfigurationRoute = createRoute({ }); // delete configuration -export const deleteAgentConfigurationRoute = createRoute({ +const deleteAgentConfigurationRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -73,20 +76,22 @@ export const deleteAgentConfigurationRoute = createRoute({ service: serviceRt, }), }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { service } = context.params.body; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { service } = params.body; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( + logger.info( `Deleting config ${service.name}/${service.environment} (${config._id})` ); @@ -98,7 +103,7 @@ export const deleteAgentConfigurationRoute = createRoute({ }); // create/update configuration -export const createOrUpdateAgentConfigurationRoute = createRoute({ +const createOrUpdateAgentConfigurationRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -107,9 +112,10 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { body, query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { body, query } = params; // if the config already exists, it is fetched and updated // this is to avoid creating two configs with identical service params @@ -125,13 +131,13 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ ); } - context.logger.info( + logger.info( `${config ? 'Updating' : 'Creating'} config ${body.service.name}/${ body.service.environment }` ); - return await createOrUpdateConfiguration({ + await createOrUpdateConfiguration({ configurationId: config?._id, configurationIntake: body, setup, @@ -147,35 +153,35 @@ const searchParamsRt = t.intersection([ export type AgentConfigSearchParams = t.TypeOf; // Lookup single configuration (used by APM Server) -export const agentConfigurationSearchRoute = createRoute({ +const agentConfigurationSearchRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/agent-configuration/search', params: t.type({ body: searchParamsRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, logger } = resources; + const { service, etag, mark_as_applied_by_agent: markAsAppliedByAgent, - } = context.params.body; + } = params.body; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const config = await searchConfigurations({ service, setup, }); if (!config) { - context.logger.debug( + logger.debug( `[Central configuration] Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( - `Config was found for ${service.name}/${service.environment}` - ); + logger.info(`Config was found for ${service.name}/${service.environment}`); // update `applied_by_agent` field // when `markAsAppliedByAgent` is true (Jaeger agent doesn't have etags) @@ -197,11 +203,11 @@ export const agentConfigurationSearchRoute = createRoute({ */ // get list of services -export const listAgentConfigurationServicesRoute = createRoute({ +const listAgentConfigurationServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/services', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -215,15 +221,17 @@ export const listAgentConfigurationServicesRoute = createRoute({ }); // get environments for service -export const listAgentConfigurationEnvironmentsRoute = createRoute({ +const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/environments', params: t.partial({ query: t.partial({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -239,16 +247,27 @@ export const listAgentConfigurationEnvironmentsRoute = createRoute({ }); // get agentName for service -export const agentConfigurationAgentNameRoute = createRoute({ +const agentConfigurationAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/agent_name', params: t.type({ query: t.type({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const agentName = await getAgentNameByService({ serviceName, setup }); return { agentName }; }, }); + +export const agentConfigurationRouteRepository = createApmServerRouteRepository() + .add(agentConfigurationRoute) + .add(getSingleAgentConfigurationRoute) + .add(deleteAgentConfigurationRoute) + .add(createOrUpdateAgentConfigurationRoute) + .add(agentConfigurationSearchRoute) + .add(listAgentConfigurationServicesRoute) + .add(listAgentConfigurationEnvironmentsRoute) + .add(agentConfigurationAgentNameRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index de7f35c4081bc..98467e1a4a0dd 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { ML_ERRORS } from '../../../common/anomaly_detection'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../lib/helpers/setup_request'; @@ -18,15 +18,17 @@ import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; import { notifyFeatureUsage } from '../../feature'; import { withApmSpan } from '../../utils/with_apm_span'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get ML anomaly detection jobs for each environment -export const anomalyDetectionJobsRoute = createRoute({ +const anomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:ml:canGetJobs'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { context, logger } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); @@ -34,7 +36,7 @@ export const anomalyDetectionJobsRoute = createRoute({ const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), + getAnomalyDetectionJobs(setup, logger), hasLegacyJobs(setup), ]) ); @@ -47,7 +49,7 @@ export const anomalyDetectionJobsRoute = createRoute({ }); // create new ML anomaly detection jobs for each given environment -export const createAnomalyDetectionJobsRoute = createRoute({ +const createAnomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:apm_write', 'access:ml:canCreateJob'], @@ -57,15 +59,17 @@ export const createAnomalyDetectionJobsRoute = createRoute({ environments: t.array(t.string), }), }), - handler: async ({ context, request }) => { - const { environments } = context.params.body; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params, context, logger } = resources; + const { environments } = params.body; + + const setup = await setupRequest(resources); if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - await createAnomalyDetectionJobs(setup, environments, context.logger); + await createAnomalyDetectionJobs(setup, environments, logger); notifyFeatureUsage({ licensingPlugin: context.licensing, @@ -77,11 +81,11 @@ export const createAnomalyDetectionJobsRoute = createRoute({ }); // get all available environments to create anomaly detection jobs for -export const anomalyDetectionEnvironmentsRoute = createRoute({ +const anomalyDetectionEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/environments', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +100,8 @@ export const anomalyDetectionEnvironmentsRoute = createRoute({ return { environments }; }, }); + +export const anomalyDetectionRouteRepository = createApmServerRouteRepository() + .add(anomalyDetectionJobsRoute) + .add(createAnomalyDetectionJobsRoute) + .add(anomalyDetectionEnvironmentsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts index 91057c97579e4..003471aa89f39 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts @@ -6,7 +6,8 @@ */ import * as t from 'io-ts'; -import { createRoute } from '../create_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getApmIndices, getApmIndexSettings, @@ -14,29 +15,30 @@ import { import { saveApmIndices } from '../../lib/settings/apm_indices/save_apm_indices'; // get list of apm indices and values -export const apmIndexSettingsRoute = createRoute({ +const apmIndexSettingsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-index-settings', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const apmIndexSettings = await getApmIndexSettings({ context }); + handler: async ({ config, context }) => { + const apmIndexSettings = await getApmIndexSettings({ config, context }); return { apmIndexSettings }; }, }); // get apm indices configuration object -export const apmIndicesRoute = createRoute({ +const apmIndicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-indices', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async (resources) => { + const { context, config } = resources; return await getApmIndices({ savedObjectsClient: context.core.savedObjects.client, - config: context.config, + config, }); }, }); // save ui indices -export const saveApmIndicesRoute = createRoute({ +const saveApmIndicesRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/apm-indices/save', options: { tags: ['access:apm', 'access:apm_write'], @@ -53,9 +55,15 @@ export const saveApmIndicesRoute = createRoute({ /* eslint-enable @typescript-eslint/naming-convention */ }), }), - handler: async ({ context }) => { - const { body } = context.params; + handler: async (resources) => { + const { params, context } = resources; + const { body } = params; const savedObjectsClient = context.core.savedObjects.client; return await saveApmIndices(savedObjectsClient, body); }, }); + +export const apmIndicesRouteRepository = createApmServerRouteRepository() + .add(apmIndexSettingsRoute) + .add(apmIndicesRoute) + .add(saveApmIndicesRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index a6ab553f09419..c9c5d236c14f9 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -21,35 +21,40 @@ import { import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; -export const customLinkTransactionRoute = createRoute({ +const customLinkTransactionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links/transaction', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { query } = params; // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); return await getTransaction({ setup, filters }); }, }); -export const listCustomLinksRoute = createRoute({ +const listCustomLinksRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { query } = context.params; + const setup = await setupRequest(resources); + + const { query } = params; + // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); const customLinks = await listCustomLinks({ setup, filters }); @@ -57,29 +62,30 @@ export const listCustomLinksRoute = createRoute({ }, }); -export const createCustomLinkRoute = createRoute({ +const createCustomLinkRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/custom_links', params: t.type({ body: payloadRt, }), options: { tags: ['access:apm', 'access:apm_write'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ customLink, setup }); + const setup = await setupRequest(resources); + const customLink = params.body; notifyFeatureUsage({ licensingPlugin: context.licensing, featureName: 'customLinks', }); - return res; + + await createOrUpdateCustomLink({ customLink, setup }); }, }); -export const updateCustomLinkRoute = createRoute({ +const updateCustomLinkRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -90,23 +96,26 @@ export const updateCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ + const setup = await setupRequest(resources); + + const { id } = params.path; + const customLink = params.body; + + await createOrUpdateCustomLink({ customLinkId: id, customLink, setup, }); - return res; }, }); -export const deleteCustomLinkRoute = createRoute({ +const deleteCustomLinkRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -116,12 +125,14 @@ export const deleteCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; + const setup = await setupRequest(resources); + const { id } = params.path; const res = await deleteCustomLink({ customLinkId: id, setup, @@ -129,3 +140,10 @@ export const deleteCustomLinkRoute = createRoute({ return res; }, }); + +export const customLinkRouteRepository = createApmServerRouteRepository() + .add(customLinkTransactionRoute) + .add(listCustomLinksRoute) + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute); diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 6287ffbf0c751..dd392982b02fd 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -9,20 +9,22 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTrace } from '../lib/traces/get_trace'; import { getTransactionGroupList } from '../lib/transaction_groups'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getRootTransactionByTraceId } from '../lib/transactions/get_transaction_by_trace'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const tracesRoute = createRoute({ +const tracesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -34,7 +36,7 @@ export const tracesRoute = createRoute({ }, }); -export const tracesByIdRoute = createRoute({ +const tracesByIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}', params: t.type({ path: t.type({ @@ -43,13 +45,16 @@ export const tracesByIdRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - return getTrace(context.params.path.traceId, setup); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { traceId } = params.path; + return getTrace(traceId, setup); }, }); -export const rootTransactionByTraceIdRoute = createRoute({ +const rootTransactionByTraceIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}/root_transaction', params: t.type({ path: t.type({ @@ -57,9 +62,15 @@ export const rootTransactionByTraceIdRoute = createRoute({ }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const { traceId } = context.params.path; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const { traceId } = params.path; + const setup = await setupRequest(resources); return getRootTransactionByTraceId(traceId, setup); }, }); + +export const traceRouteRepository = createApmServerRouteRepository() + .add(tracesByIdRoute) + .add(tracesRoute) + .add(rootTransactionByTraceIdRoute); diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index f3424a252e409..ebca374db86d7 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -5,12 +5,12 @@ * 2.0. */ +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { LatencyAggregationType, latencyAggregationTypeRt, } from '../../common/latency_aggregation_types'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -23,7 +23,8 @@ import { getLatencyPeriods } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; import { getTransactionGroupList } from '../lib/transaction_groups'; import { getErrorRatePeriods } from '../lib/transaction_groups/get_error_rate'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -35,7 +36,7 @@ import { * Returns a list of transactions grouped by name * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ */ -export const transactionGroupsRoute = createRoute({ +const transactionGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', params: t.type({ path: t.type({ @@ -49,10 +50,11 @@ export const transactionGroupsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, kuery, transactionType } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -72,7 +74,7 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsPrimaryStatisticsRoute = createRoute({ +const transactionGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', params: t.type({ @@ -90,8 +92,9 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -100,7 +103,7 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, - } = context.params; + } = params; return getServiceTransactionGroups({ environment, @@ -109,12 +112,12 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ serviceName, searchAggregatedTransactions, transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }); }, }); -export const transactionGroupsComparisonStatisticsRoute = createRoute({ +const transactionGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', params: t.type({ @@ -135,13 +138,15 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); + const { params } = resources; + const { path: { serviceName }, query: { @@ -154,7 +159,7 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return await getServiceTransactionGroupComparisonStatisticsPeriods({ environment, @@ -165,14 +170,14 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ searchAggregatedTransactions, transactionType, numBuckets, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, comparisonStart, comparisonEnd, }); }, }); -export const transactionLatencyChartsRoute = createRoute({ +const transactionLatencyChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', params: t.type({ path: t.type({ @@ -188,10 +193,11 @@ export const transactionLatencyChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const logger = context.logger; - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { serviceName } = params.path; const { environment, kuery, @@ -200,7 +206,7 @@ export const transactionLatencyChartsRoute = createRoute({ latencyAggregationType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -242,7 +248,7 @@ export const transactionLatencyChartsRoute = createRoute({ }, }); -export const transactionThroughputChartsRoute = createRoute({ +const transactionThroughputChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/throughput', params: t.type({ @@ -258,15 +264,17 @@ export const transactionThroughputChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionType, transactionName, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -284,7 +292,7 @@ export const transactionThroughputChartsRoute = createRoute({ }, }); -export const transactionChartsDistributionRoute = createRoute({ +const transactionChartsDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', params: t.type({ @@ -306,9 +314,10 @@ export const transactionChartsDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -316,7 +325,7 @@ export const transactionChartsDistributionRoute = createRoute({ transactionName, transactionId = '', traceId = '', - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -336,7 +345,7 @@ export const transactionChartsDistributionRoute = createRoute({ }, }); -export const transactionChartsBreakdownRoute = createRoute({ +const transactionChartsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown', params: t.type({ path: t.type({ @@ -351,15 +360,17 @@ export const transactionChartsBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionName, transactionType, - } = context.params.query; + } = params.query; return getTransactionBreakdown({ environment, @@ -372,7 +383,7 @@ export const transactionChartsBreakdownRoute = createRoute({ }, }); -export const transactionChartsErrorRateRoute = createRoute({ +const transactionChartsErrorRateRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', params: t.type({ @@ -386,9 +397,10 @@ export const transactionChartsErrorRateRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; const { serviceName } = params.path; const { environment, @@ -416,3 +428,13 @@ export const transactionChartsErrorRateRoute = createRoute({ }); }, }); + +export const transactionRouteRepository = createApmServerRouteRepository() + .add(transactionGroupsRoute) + .add(transactionGroupsPrimaryStatisticsRoute) + .add(transactionGroupsComparisonStatisticsRoute) + .add(transactionLatencyChartsRoute) + .add(transactionThroughputChartsRoute) + .add(transactionChartsDistributionRoute) + .add(transactionChartsBreakdownRoute) + .add(transactionChartsErrorRateRoute); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 3ba24b4ed5268..517387c5f74ef 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -5,27 +5,19 @@ * 2.0. */ -import t, { Encode, Encoder } from 'io-ts'; import { CoreSetup, - KibanaRequest, RequestHandlerContext, Logger, + KibanaRequest, + CoreStart, } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { RequiredKeys, DeepPartial } from 'utility-types'; -import { SpacesPluginStart } from '../../../spaces/server'; -import { ObservabilityPluginSetup } from '../../../observability/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; -import { SecurityPluginSetup } from '../../../security/server'; -import { MlPluginSetup } from '../../../ml/server'; -import { FetchOptions } from '../../common/fetch_options'; import { APMConfig } from '..'; +import { APMPluginDependencies } from '../types'; -export type HandlerReturn = Record; - -interface InspectQueryParam { - query: { _inspect: boolean }; +export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { + licensing: LicensingApiRequestHandlerContext; } export type InspectResponse = Array<{ @@ -36,141 +28,35 @@ export type InspectResponse = Array<{ esError: Error; }>; -export interface RouteParams { - path?: Record; - query?: Record; - body?: any; +export interface APMRouteCreateOptions { + options: { + tags: Array< + | 'access:apm' + | 'access:apm_write' + | 'access:ml:canGetJobs' + | 'access:ml:canCreateJob' + >; + }; } -type WithoutIncompatibleMethods = Omit< - T, - 'encode' | 'asEncoder' -> & { encode: Encode; asEncoder: () => Encoder }; - -export type RouteParamsRT = WithoutIncompatibleMethods>; - -export type RouteHandler< - TParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> = (kibanaContext: { - context: APMRequestHandlerContext< - (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & - InspectQueryParam - >; +export interface APMRouteHandlerResources { request: KibanaRequest; -}) => Promise; - -interface RouteOptions { - tags: Array< - | 'access:apm' - | 'access:apm_write' - | 'access:ml:canGetJobs' - | 'access:ml:canCreateJob' - >; -} - -export interface Route< - TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> { - endpoint: TEndpoint; - options: RouteOptions; - params?: TRouteParamsRT; - handler: RouteHandler; -} - -/** - * @internal - */ -export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { - licensing: LicensingApiRequestHandlerContext; -} - -export type APMRequestHandlerContext< - TRouteParams = {} -> = ApmPluginRequestHandlerContext & { - params: TRouteParams & InspectQueryParam; + context: ApmPluginRequestHandlerContext; + params: { + query: { + _inspect: boolean; + }; + }; config: APMConfig; logger: Logger; - plugins: { - spaces?: SpacesPluginStart; - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; + core: { + setup: CoreSetup; + start: () => Promise; }; -}; - -export interface RouteState { - [endpoint: string]: { - params?: RouteParams; - ret: any; + plugins: { + [key in keyof APMPluginDependencies]: { + setup: Required[key]['setup']; + start: () => Promise[key]['start']>; + }; }; } - -export interface ServerAPI { - _S: TRouteState; - add< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined - >( - route: - | Route - | ((core: CoreSetup) => Route) - ): ServerAPI< - TRouteState & - { - [key in TEndpoint]: { - params: TRouteParamsRT; - ret: TReturn & { _inspect?: InspectResponse }; - }; - } - >; - init: ( - core: CoreSetup, - context: { - config$: Observable; - logger: Logger; - plugins: { - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - }; - } - ) => void; -} - -type MaybeOptional }> = RequiredKeys< - T['params'] -> extends never - ? { params?: T['params'] } - : { params: T['params'] }; - -export type MaybeParams< - TRouteState, - TEndpoint extends keyof TRouteState & string -> = TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ - params: t.OutputOf & - DeepPartial; - }> - : {}; - -export type Client< - TRouteState, - TOptions extends { abortable: boolean } = { abortable: true } -> = ( - options: Omit< - FetchOptions, - 'query' | 'body' | 'pathname' | 'method' | 'signal' - > & { - forceCache?: boolean; - endpoint: TEndpoint; - } & MaybeParams & - (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) -) => Promise< - TRouteState[TEndpoint] extends { ret: any } - ? TRouteState[TEndpoint]['ret'] - : unknown ->; diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts new file mode 100644 index 0000000000000..dbc220f9f6b15 --- /dev/null +++ b/x-pack/plugins/apm/server/types.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ValuesType } from 'utility-types'; +import { Observable } from 'rxjs'; +import { CoreSetup, CoreStart, KibanaRequest } from 'kibana/server'; +import { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '../../../../src/plugins/data/server'; +import { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; +import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { + HomeServerPluginSetup, + HomeServerPluginStart, +} from '../../../../src/plugins/home/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ActionsPlugin } from '../../actions/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { CloudSetup } from '../../cloud/server'; +import { + PluginSetupContract as FeaturesPluginSetup, + PluginStartContract as FeaturesPluginStart, +} from '../../features/server'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../licensing/server'; +import { MlPluginSetup, MlPluginStart } from '../../ml/server'; +import { ObservabilityPluginSetup } from '../../observability/server'; +import { + SecurityPluginSetup, + SecurityPluginStart, +} from '../../security/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../task_manager/server'; +import { APMConfig } from '.'; +import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; +import { ApmPluginRequestHandlerContext } from './routes/typings'; + +export interface APMPluginSetup { + config$: Observable; + getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: ApmPluginRequestHandlerContext; + }) => Promise>; +} + +interface DependencyMap { + core: { + setup: CoreSetup; + start: CoreStart; + }; + spaces: { + setup: SpacesPluginSetup; + start: SpacesPluginStart; + }; + apmOss: { + setup: APMOSSPluginSetup; + start: undefined; + }; + home: { + setup: HomeServerPluginSetup; + start: HomeServerPluginStart; + }; + licensing: { + setup: LicensingPluginSetup; + start: LicensingPluginStart; + }; + cloud: { + setup: CloudSetup; + start: undefined; + }; + usageCollection: { + setup: UsageCollectionSetup; + start: undefined; + }; + taskManager: { + setup: TaskManagerSetupContract; + start: TaskManagerStartContract; + }; + alerting: { + setup: AlertingPlugin['setup']; + start: AlertingPlugin['start']; + }; + actions: { + setup: ActionsPlugin['setup']; + start: ActionsPlugin['start']; + }; + observability: { + setup: ObservabilityPluginSetup; + start: undefined; + }; + features: { + setup: FeaturesPluginSetup; + start: FeaturesPluginStart; + }; + security: { + setup: SecurityPluginSetup; + start: SecurityPluginStart; + }; + ml: { + setup: MlPluginSetup; + start: MlPluginStart; + }; + data: { + setup: DataPluginSetup; + start: DataPluginStart; + }; +} + +const requiredDependencies = [ + 'features', + 'apmOss', + 'data', + 'licensing', + 'triggersActionsUi', + 'embeddable', + 'infra', + 'observability', +] as const; + +const optionalDependencies = [ + 'spaces', + 'cloud', + 'usageCollection', + 'taskManager', + 'actions', + 'alerting', + 'security', + 'ml', + 'home', + 'maps', +] as const; + +type RequiredDependencies = Pick< + DependencyMap, + ValuesType & keyof DependencyMap +>; + +type OptionalDependencies = Partial< + Pick< + DependencyMap, + ValuesType & keyof DependencyMap + > +>; + +export type APMPluginDependencies = RequiredDependencies & OptionalDependencies; + +export type APMPluginSetupDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['setup']; +}; + +export type APMPluginStartDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['start']; +}; diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json index ffbf11c23f63a..bb341059e2d43 100644 --- a/x-pack/plugins/apm/tsconfig.json +++ b/x-pack/plugins/apm/tsconfig.json @@ -38,6 +38,7 @@ { "path": "../ml/tsconfig.json" }, { "path": "../observability/tsconfig.json" }, { "path": "../reporting/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../triggers_actions_ui/tsconfig.json" } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts index 57af8cada9890..99f3d340f8430 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiButtonIconColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; export const CURATIONS_TITLE = i18n.translate( @@ -49,26 +50,26 @@ export const PROMOTE_DOCUMENT_ACTION = { defaultMessage: 'Promote this result', }), iconType: 'starPlusEmpty', - iconColor: 'primary', + iconColor: 'primary' as EuiButtonIconColor, }; export const DEMOTE_DOCUMENT_ACTION = { title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.demoteButtonLabel', { defaultMessage: 'Demote this result', }), iconType: 'starMinusFilled', - iconColor: 'primary', + iconColor: 'primary' as EuiButtonIconColor, }; export const HIDE_DOCUMENT_ACTION = { title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.hideButtonLabel', { defaultMessage: 'Hide this result', }), iconType: 'eyeClosed', - iconColor: 'danger', + iconColor: 'danger' as EuiButtonIconColor, }; export const SHOW_DOCUMENT_ACTION = { title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.showButtonLabel', { defaultMessage: 'Show this result', }), iconType: 'eye', - iconColor: 'primary', + iconColor: 'primary' as EuiButtonIconColor, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index ad693628d911e..9ad32c6e48632 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -16,6 +16,7 @@ import { EuiDragDropContext, EuiDroppable, EuiDraggable, + EuiButtonIconColor, } from '@elastic/eui'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; @@ -78,7 +79,7 @@ export const Library: React.FC = () => { title: 'Fill this action button', onClick: () => setIsActionButtonFilled(!isActionButtonFilled), iconType: isActionButtonFilled ? 'starFilled' : 'starEmpty', - iconColor: 'primary', + iconColor: 'primary' as EuiButtonIconColor, }, ]; @@ -221,7 +222,7 @@ export const Library: React.FC = () => {

With custom actions and a link

- + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts index 89909c1e51d3f..a7eed3b95e6fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts @@ -6,4 +6,5 @@ */ export { ResultFieldValue } from './result_field_value'; +export { ResultToken } from './result_token'; export { Result } from './result'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss index 5f1b165f2c362..3132894ddc7a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss @@ -6,6 +6,7 @@ 'drag content actions' 'drag toggle actions'; overflow: hidden; // Prevents child background-colors from clipping outside of panel border-radius + border: $euiBorderThin; // TODO: Remove after EUI version is bumped beyond 31.8.0 &__content { grid-area: content; @@ -44,9 +45,13 @@ display: flex; justify-content: center; align-items: center; - width: $euiSizeL * 2; + width: $euiSize * 2; border-left: $euiBorderThin; + &:first-child { + border-left: none; + } + &:hover, &:focus { background-color: $euiPageBackgroundColor; @@ -62,22 +67,3 @@ border-right: $euiBorderThin; } } - -/** - * CSS for hover specific logic - * It's mildly horrific, so I pulled it out to its own section here - */ - -.appSearchResult--link { - &:hover, - &:focus { - @include euiSlightShadowHover; - } -} -.appSearchResult__content--link:hover { - cursor: pointer; - - & ~ .appSearchResult__actionButtons .appSearchResult__actionButton--link { - background-color: $euiPageBackgroundColor; - } -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx index 15c9ee2967d3e..3e83717bf9355 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx @@ -10,9 +10,8 @@ import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPanel } from '@elastic/eui'; +import { EuiButtonIcon, EuiPanel, EuiButtonIconColor } from '@elastic/eui'; -import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; import { SchemaTypes } from '../../../shared/types'; import { Result } from './result'; @@ -64,37 +63,18 @@ describe('Result', () => { ]); }); - it('passes showScore, resultMeta, and isMetaEngine to ResultHeader', () => { + it('renders a header', () => { const wrapper = shallow(); - expect(wrapper.find(ResultHeader).props()).toEqual({ - isMetaEngine: true, - showScore: true, - resultMeta: { - id: '1', - score: 100, - engine: 'my-engine', - }, - }); - }); - - describe('document detail link', () => { - it('will render a link if shouldLinkToDetailPage is true', () => { - const wrapper = shallow(); - wrapper.find(ReactRouterHelper).forEach((link) => { - expect(link.prop('to')).toEqual('/engines/my-engine/documents/1'); - }); - expect(wrapper.hasClass('appSearchResult--link')).toBe(true); - expect(wrapper.find('.appSearchResult__content--link').exists()).toBe(true); - expect(wrapper.find('.appSearchResult__actionButton--link').exists()).toBe(true); - }); - - it('will not render a link if shouldLinkToDetailPage is not set', () => { - const wrapper = shallow(); - expect(wrapper.find(ReactRouterHelper).exists()).toBe(false); - expect(wrapper.hasClass('appSearchResult--link')).toBe(false); - expect(wrapper.find('.appSearchResult__content--link').exists()).toBe(false); - expect(wrapper.find('.appSearchResult__actionButton--link').exists()).toBe(false); - }); + const header = wrapper.find(ResultHeader); + expect(header.exists()).toBe(true); + expect(header.prop('isMetaEngine')).toBe(true); // passed through from props + expect(header.prop('showScore')).toBe(true); // passed through from props + expect(header.prop('shouldLinkToDetailPage')).toBe(false); // passed through from props + expect(header.prop('resultMeta')).toEqual({ + id: '1', + score: 100, + engine: 'my-engine', + }); // passed through from meta in result prop }); describe('actions', () => { @@ -103,30 +83,53 @@ describe('Result', () => { title: 'Hide', onClick: jest.fn(), iconType: 'eyeClosed', - iconColor: 'danger', + iconColor: 'danger' as EuiButtonIconColor, }, { title: 'Bookmark', onClick: jest.fn(), iconType: 'starFilled', - iconColor: 'primary', + iconColor: undefined, }, ]; - it('will render an action button for each action passed', () => { + it('will render an action button in the header for each action passed', () => { const wrapper = shallow(); - expect(wrapper.find('.appSearchResult__actionButton')).toHaveLength(2); - - wrapper.find('.appSearchResult__actionButton').first().simulate('click'); + const header = wrapper.find(ResultHeader); + const renderedActions = shallow(header.prop('actions') as any); + const buttons = renderedActions.find(EuiButtonIcon); + expect(buttons).toHaveLength(2); + + expect(buttons.first().prop('iconType')).toEqual('eyeClosed'); + expect(buttons.first().prop('color')).toEqual('danger'); + buttons.first().simulate('click'); expect(actions[0].onClick).toHaveBeenCalled(); - wrapper.find('.appSearchResult__actionButton').last().simulate('click'); + expect(buttons.last().prop('iconType')).toEqual('starFilled'); + // Note that no iconColor was passed so it was defaulted to primary + expect(buttons.last().prop('color')).toEqual('primary'); + buttons.last().simulate('click'); expect(actions[1].onClick).toHaveBeenCalled(); }); - it('will render custom actions seamlessly next to the document detail link', () => { + it('will render a document detail link as the first action if shouldLinkToDetailPage is passed', () => { const wrapper = shallow(); - expect(wrapper.find('.appSearchResult__actionButton')).toHaveLength(3); + const header = wrapper.find(ResultHeader); + const renderedActions = shallow(header.prop('actions') as any); + const buttons = renderedActions.find(EuiButtonIcon); + + // In addition to the 2 actions passed, we also have a link action + expect(buttons).toHaveLength(3); + + expect(buttons.first().prop('data-test-subj')).toEqual('DocumentDetailLink'); + }); + + it('will not render anything if no actions are passed and shouldLinkToDetailPage is false', () => { + const wrapper = shallow(); + const header = wrapper.find(ResultHeader); + const renderedActions = shallow(header.prop('actions') as any); + const buttons = renderedActions.find(EuiButtonIcon); + expect(buttons).toHaveLength(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx index 89208a041af35..71d9f39d802d5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx @@ -8,16 +8,16 @@ import React, { useState, useMemo } from 'react'; import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; -import classNames from 'classnames'; - import './result.scss'; -import { EuiPanel, EuiIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; import { Schema } from '../../../shared/types'; + import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes'; import { generateEncodedPath } from '../../utils/encode_path_params'; @@ -56,34 +56,54 @@ export const Result: React.FC = ({ [result] ); const numResults = resultFields.length; - const typeForField = (fieldName: string) => { - if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName]; - }; - const documentLink = generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, { engineName: resultMeta.engine, documentId: resultMeta.id, }); - const conditionallyLinkedArticle = (children: React.ReactNode) => { - return shouldLinkToDetailPage ? ( - -
- {children} -
-
- ) : ( -
{children}
- ); + + const typeForField = (fieldName: string) => { + if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName]; }; - const classes = classNames('appSearchResult', { - 'appSearchResult--link': shouldLinkToDetailPage, - }); + const ResultActions = () => { + if (!shouldLinkToDetailPage && !actions.length) return null; + return ( + + + {shouldLinkToDetailPage && ( + + + + + + )} + {actions.map(({ onClick, title, iconType, iconColor }) => ( + + + + ))} + + + ); + }; return ( = ({
)} - {conditionallyLinkedArticle( - <> - - {resultFields - .slice(0, isOpen ? resultFields.length : RESULT_CUTOFF) - .map(([field, value]: [string, FieldValue]) => ( - - ))} - - )} +
+ } + /> + {resultFields + .slice(0, isOpen ? resultFields.length : RESULT_CUTOFF) + .map(([field, value]: [string, FieldValue]) => ( + + ))} +
{numResults > RESULT_CUTOFF && ( )} -
- {shouldLinkToDetailPage && ( - - - - - - )} - {actions.map(({ onClick, title, iconType, iconColor }) => ( - - ))} -
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss index 80d60e2de67e2..443c130548f6b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss @@ -14,6 +14,16 @@ } } + &__key { + display: flex; + align-items: center; + @include euiCodeFont; + + .euiToken { + margin-right: $euiSizeS; + } + } + &__value { padding-left: $euiSize; overflow: hidden; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx index 003810ec40a8d..9b78d769d2f30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { FieldType, Raw, Snippet } from './types'; -import { ResultFieldValue } from '.'; +import { ResultFieldValue, ResultToken } from '.'; import './result_field.scss'; @@ -23,7 +23,10 @@ interface Props { export const ResultField: React.FC = ({ field, raw, snippet, type }) => { return (
-
{field}
+
+ {type && } + {field} +
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss index 73372d7c4aca0..cd1042998dd34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss @@ -1,7 +1,5 @@ .appSearchResultHeader { display: flex; - justify-content: space-between; - align-items: flex-start; margin-bottom: $euiSizeS; @include euiBreakpoint('xs') { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx index dcefd0f6bc0b0..80cff9b96a3ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx @@ -30,6 +30,22 @@ describe('ResultHeader', () => { ); expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); + expect(wrapper.find('[data-test-subj="ResultId"]').prop('href')).toBeUndefined(); + }); + + it('renders id as a link if shouldLinkToDetailPage is true', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); + expect(wrapper.find('[data-test-subj="ResultId"]').prop('href')).toEqual( + '/engines/my-engine/documents/1' + ); }); describe('score', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx index 550093a8cd904..93a684b1968a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx @@ -7,6 +7,11 @@ import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes'; +import { generateEncodedPath } from '../../utils/encode_path_params'; + import { ResultHeaderItem } from './result_header_item'; import { ResultMeta } from './types'; @@ -16,33 +21,56 @@ interface Props { showScore: boolean; isMetaEngine: boolean; resultMeta: ResultMeta; + actions?: React.ReactNode; + shouldLinkToDetailPage?: boolean; } -export const ResultHeader: React.FC = ({ showScore, resultMeta, isMetaEngine }) => { +export const ResultHeader: React.FC = ({ + showScore, + resultMeta, + isMetaEngine, + actions, + shouldLinkToDetailPage = false, +}) => { + const documentLink = generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, { + engineName: resultMeta.engine, + documentId: resultMeta.id, + }); + return ( -
- {showScore && ( -
+
+ + -
- )} - -
+ + {showScore && ( + + + + )} {isMetaEngine && ( - + + + )} - -
+ {actions} +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss index f1e9343530af3..df3e2ec241106 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss @@ -1,16 +1,12 @@ -.appSearchResultHeaderItem { - display: flex; +.euiFlexItem:not(:first-child):not(:last-child) .appSearchResultHeaderItem { + padding-right: .75rem; + box-shadow: inset -1px 0 0 0 $euiBorderColor; +} - &__key, - &__value { - line-height: $euiLineHeight; - font-size: $euiFontSizeXS; - } +.appSearchResultHeaderItem { + @include euiCodeFont; - &__key { - text-transform: uppercase; - font-weight: $euiFontWeightLight; - color: $euiColorDarkShade; - margin-right: $euiSizeXS; + &__score { + color: $euiColorSuccessText; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx index 52fa81943bb2e..e0407b4db7f25 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx @@ -7,15 +7,14 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { ResultHeaderItem } from './result_header_item'; describe('ResultHeaderItem', () => { it('renders', () => { const wrapper = mount(); - expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual('id'); - expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('001'); + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain('001'); }); it('will truncate long field names', () => { @@ -26,7 +25,7 @@ describe('ResultHeaderItem', () => { type="string" /> ); - expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual( + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain( 'a-really-really-really-really-…' ); }); @@ -35,7 +34,7 @@ describe('ResultHeaderItem', () => { const wrapper = mount( ); - expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain( 'a-really-really-really-really-…' ); }); @@ -44,18 +43,33 @@ describe('ResultHeaderItem', () => { const wrapper = mount( ); - expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain( '…lly-really-really-really-value' ); }); it('will round any numeric values that are passed in to 2 decimals, regardless of the explicit "type" passed', () => { const wrapper = mount(); - expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('5.19'); + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain('5.19'); }); it('if the value passed in is undefined, it will render "-"', () => { const wrapper = mount(); - expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('-'); + expect(wrapper.find('.appSearchResultHeaderItem').text()).toContain('-'); + }); + + it('it will add a "score" class if the "type" passed is "score"', () => { + const wrapper = shallow(); + expect( + wrapper.find('.appSearchResultHeaderItem').hasClass('appSearchResultHeaderItem__score') + ).toBe(true); + }); + + it('it will render as a link if an href is passed', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('ReactRouterHelper').exists()).toBe(true); + expect(wrapper.find('ReactRouterHelper').prop('to')).toBe('http://www.example.com'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx index cda56fbc4797c..545b85c17a529 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx @@ -9,17 +9,20 @@ import React from 'react'; import './result_header_item.scss'; +import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; + import { TruncatedContent } from '../../../shared/truncate'; interface Props { field: string; value?: string | number; type: 'id' | 'score' | 'string'; + href?: string; } const MAX_CHARACTER_LENGTH = 30; -export const ResultHeaderItem: React.FC = ({ field, type, value }) => { +export const ResultHeaderItem: React.FC = ({ field, type, value, href }) => { let formattedValue = '-'; if (typeof value === 'string') { formattedValue = value; @@ -27,19 +30,32 @@ export const ResultHeaderItem: React.FC = ({ field, type, value }) => { formattedValue = parseFloat((value as number).toFixed(2)).toString(); } + const HeaderItemContent = () => ( + + ); + return ( -
-
- -
-
- -
-
+ + +   + {href ? ( + + + + + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.test.tsx new file mode 100644 index 0000000000000..d50b35198acb9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiToken } from '@elastic/eui'; + +import { ResultToken } from './result_token'; + +describe('ResultToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('render a token icon based on the provided field type', () => { + expect( + shallow() + .find(EuiToken) + .prop('iconType') + ).toBe('tokenString'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx new file mode 100644 index 0000000000000..773fcd19ce9ea --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiToken } from '@elastic/eui'; + +import { FieldType } from './types'; + +interface Props { + fieldType: FieldType; +} + +const fieldTypeToTokenMap = { + text: 'tokenString', + string: 'tokenString', + number: 'tokenNumber', + float: 'tokenNumber', + location: 'tokenGeo', + geolocation: 'tokenGeo', + date: 'tokenDate', +}; + +export const ResultToken: React.FC = ({ fieldType }) => { + return ; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts index 96a135b0db36e..638a76511deee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiButtonIconColor } from '@elastic/eui'; + import { InternalSchemaTypes, SchemaTypes } from '../../../shared/types'; export type FieldType = InternalSchemaTypes | SchemaTypes; @@ -38,5 +40,5 @@ export interface ResultAction { onClick(): void; title: string; iconType: string; - iconColor?: string; + iconColor?: EuiButtonIconColor; } diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index efe3186f97805..cd87bc8e6e18b 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -12,7 +12,6 @@ import { IClusterClientAdapter, EVENT_BUFFER_LENGTH, } from './cluster_client_adapter'; -import { contextMock } from './context.mock'; import { findOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; import { times } from 'lodash'; @@ -31,7 +30,7 @@ beforeEach(() => { clusterClientAdapter = new ClusterClientAdapter({ logger, elasticsearchClientPromise: Promise.resolve(clusterClient), - context: contextMock.create(), + wait: () => Promise.resolve(true), }); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 5d7be2278d55d..dd6ac6350d6e3 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -10,8 +10,8 @@ import { bufferTime, filter as rxFilter, switchMap } from 'rxjs/operators'; import { reject, isUndefined, isNumber } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, ElasticsearchClient } from 'src/core/server'; +import util from 'util'; import { estypes } from '@elastic/elasticsearch'; -import { EsContext } from '.'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; import { esKuery } from '../../../../../src/plugins/data/server'; @@ -26,10 +26,12 @@ export interface Doc { body: IEvent; } +type Wait = () => Promise; + export interface ConstructorOpts { logger: Logger; elasticsearchClientPromise: Promise; - context: EsContext; + wait: Wait; } export interface QueryEventsBySavedObjectResult { @@ -39,18 +41,21 @@ export interface QueryEventsBySavedObjectResult { data: IValidatedEvent[]; } -export class ClusterClientAdapter { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AliasAny = any; + +export class ClusterClientAdapter { private readonly logger: Logger; private readonly elasticsearchClientPromise: Promise; - private readonly docBuffer$: Subject; - private readonly context: EsContext; + private readonly docBuffer$: Subject; + private readonly wait: Wait; private readonly docsBufferedFlushed: Promise; constructor(opts: ConstructorOpts) { this.logger = opts.logger; this.elasticsearchClientPromise = opts.elasticsearchClientPromise; - this.context = opts.context; - this.docBuffer$ = new Subject(); + this.wait = opts.wait; + this.docBuffer$ = new Subject(); // buffer event log docs for time / buffer length, ignore empty // buffers, then index the buffered docs; kick things off with a @@ -75,18 +80,21 @@ export class ClusterClientAdapter { await this.docsBufferedFlushed; } - public indexDocument(doc: Doc): void { + public indexDocument(doc: TDoc): void { this.docBuffer$.next(doc); } - async indexDocuments(docs: Doc[]): Promise { + async indexDocuments(docs: TDoc[]): Promise { // If es initialization failed, don't try to index. // Also, don't log here, we log the failure case in plugin startup // instead, otherwise we'd be spamming the log (if done here) - if (!(await this.context.waitTillReady())) { + if (!(await this.wait())) { + this.logger.debug(`Initialization failed, not indexing ${docs.length} documents`); return; } + this.logger.debug(`Indexing ${docs.length} documents`); + const bulkBody: Array> = []; for (const doc of docs) { @@ -98,7 +106,13 @@ export class ClusterClientAdapter { try { const esClient = await this.elasticsearchClientPromise; - await esClient.bulk({ body: bulkBody }); + const response = await esClient.bulk({ body: bulkBody }); + + if (response.body.errors) { + const error = new Error('Error writing some bulk events'); + error.stack += '\n' + util.inspect(response.body.items, { depth: null }); + this.logger.error(error); + } } catch (err) { this.logger.error( `error writing bulk events: "${err.message}"; docs: ${JSON.stringify(bulkBody)}` @@ -156,7 +170,9 @@ export class ClusterClientAdapter { // instances at the same time. const existsNow = await this.doesIndexTemplateExist(name); if (!existsNow) { - throw new Error(`error creating index template: ${err.message}`); + const error = new Error(`error creating index template: ${err.message}`); + Object.assign(error, { wrapped: err }); + throw error; } } } diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index 6d3b2208b3408..f6ae0a2002dd4 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -53,7 +53,7 @@ class EsContextImpl implements EsContext { this.esAdapter = new ClusterClientAdapter({ logger: params.logger, elasticsearchClientPromise: params.elasticsearchClientPromise, - context: this, + wait: () => this.readySignal.wait(), }); } diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index ab2b9709703c5..4c5513a7fc59c 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -20,5 +20,9 @@ export { SAVED_OBJECT_REL_PRIMARY, } from './types'; +export { ClusterClientAdapter } from './es/cluster_client_adapter'; + +export { createReadySignal } from './lib/ready_signal'; + export const config = { schema: ConfigSchema }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx index 238cba217da8e..a1ac30995f722 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx @@ -144,6 +144,7 @@ export const AgentPolicyForm: React.FunctionComponent = ({ isInvalid={Boolean(touchedFields[name] && validation[name])} > updateAgentPolicy({ [name]: e.target.value })} @@ -283,7 +284,7 @@ export const AgentPolicyForm: React.FunctionComponent = ({ }} /> - {isEditing && 'id' in agentPolicy ? ( + {isEditing && 'id' in agentPolicy && agentPolicy.is_managed !== true ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx index db88de0ba720b..9e23fc775a213 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx @@ -167,42 +167,45 @@ export const PackagePoliciesTable: React.FunctionComponent = ({ }), actions: [ { - render: (packagePolicy: InMemoryPackagePolicy) => ( - {}} - // key="packagePolicyView" - // > - // - // , - - - , - // FIXME: implement Copy package policy action - // {}} key="packagePolicyCopy"> - // - // , + render: (packagePolicy: InMemoryPackagePolicy) => { + const menuItems = [ + // FIXME: implement View package policy action + // {}} + // key="packagePolicyView" + // > + // + // , + + + , + // FIXME: implement Copy package policy action + // {}} key="packagePolicyCopy"> + // + // , + ]; + + if (!agentPolicy.is_managed) { + menuItems.push( {(deletePackagePoliciesPrompt) => { return ( @@ -220,10 +223,11 @@ export const PackagePoliciesTable: React.FunctionComponent = ({ ); }} - , - ]} - /> - ), + + ); + } + return ; + }, }, ], }, @@ -244,19 +248,21 @@ export const PackagePoliciesTable: React.FunctionComponent = ({ }} {...rest} search={{ - toolsRight: [ - - - , - ], + toolsRight: agentPolicy.is_managed + ? [] + : [ + + + , + ], box: { incremental: true, schema: true, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx index 350d6439c9d3d..3e6ca5944c380 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx @@ -12,6 +12,8 @@ import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, + EuiIconTip, + EuiTitle, EuiText, EuiSpacer, EuiButtonEmpty, @@ -84,23 +86,42 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => { - -

- {isLoading ? ( - - ) : ( - (agentPolicy && agentPolicy.name) || ( - + ) : ( + + + +

+ {(agentPolicy && agentPolicy.name) || ( + + )} +

+
+
+ {agentPolicy?.is_managed && ( + + - ) + )} -

-
+ + )}
{agentPolicy && agentPolicy.description ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx index adeb56f489ea3..56b99f645f97c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx @@ -194,17 +194,18 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ), }, { - content: ( - - ), + content: + isAgentPolicyLoading || agentPolicyData?.item?.is_managed ? undefined : ( + + ), }, ].map((item, index) => ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 8e9c549fe5609..d01d290e129b8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -341,9 +341,10 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const isAgentSelectable = (agent: Agent) => { if (!agent.active) return false; + if (!agent.policy_id) return true; - const agentPolicy = agentPolicies.find((p) => p.id === agent.policy_id); - const isManaged = agent.policy_id && agentPolicy?.is_managed === true; + const agentPolicy = agentPoliciesIndexedById[agent.policy_id]; + const isManaged = agentPolicy?.is_managed === true; return !isManaged; }; diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts index c85dc06c38286..0959a9a88704a 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts @@ -67,10 +67,9 @@ export const postEnrollmentApiKeyHandler: RequestHandler< export const deleteEnrollmentApiKeyHandler: RequestHandler< TypeOf > = async (context, request, response) => { - const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; try { - await APIKeyService.deleteEnrollmentApiKey(soClient, esClient, request.params.keyId); + await APIKeyService.deleteEnrollmentApiKey(esClient, request.params.keyId); const body: DeleteEnrollmentAPIKeyResponse = { action: 'deleted' }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy_update.ts b/x-pack/plugins/fleet/server/services/agent_policy_update.ts index dc566b2c435a6..3f5f717c94597 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy_update.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy_update.ts @@ -56,6 +56,6 @@ export async function agentPolicyUpdateEventHandler( if (action === 'deleted') { await unenrollForAgentPolicyId(soClient, esClient, agentPolicyId); - await deleteEnrollmentApiKeyForAgentPolicyId(soClient, esClient, agentPolicyId); + await deleteEnrollmentApiKeyForAgentPolicyId(esClient, agentPolicyId); } } diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 7059cc96159b9..b8a24a006a674 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -17,7 +17,7 @@ import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; import { agentPolicyService } from '../agent_policy'; import { escapeSearchQueryPhrase } from '../saved_object'; -import { createAPIKey, invalidateAPIKeys } from './security'; +import { invalidateAPIKeys } from './security'; const uuidRegex = /^\([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\)$/; @@ -77,14 +77,9 @@ export async function getEnrollmentAPIKey( /** * Invalidate an api key and mark it as inactive - * @param soClient * @param id */ -export async function deleteEnrollmentApiKey( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - id: string -) { +export async function deleteEnrollmentApiKey(esClient: ElasticsearchClient, id: string) { const enrollmentApiKey = await getEnrollmentAPIKey(esClient, id); await invalidateAPIKeys([enrollmentApiKey.api_key_id]); @@ -102,7 +97,6 @@ export async function deleteEnrollmentApiKey( } export async function deleteEnrollmentApiKeyForAgentPolicyId( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, agentPolicyId: string ) { @@ -120,7 +114,7 @@ export async function deleteEnrollmentApiKeyForAgentPolicyId( } for (const apiKey of items) { - await deleteEnrollmentApiKey(soClient, esClient, apiKey.id); + await deleteEnrollmentApiKey(esClient, apiKey.id); } } } @@ -182,19 +176,37 @@ export async function generateEnrollmentAPIKey( } const name = providedKeyName ? `${providedKeyName} (${id})` : id; - const key = await createAPIKey(soClient, name, { - // Useless role to avoid to have the privilege of the user that created the key - 'fleet-apikey-enroll': { - cluster: [], - applications: [ - { - application: '.fleet', - privileges: ['no-privileges'], - resources: ['*'], + + const { body: key } = await esClient.security + .createApiKey({ + body: { + name, + // @ts-expect-error Metadata in api keys + metadata: { + managed_by: 'fleet', + managed: true, + type: 'enroll', + policy_id: data.agentPolicyId, }, - ], - }, - }); + role_descriptors: { + // Useless role to avoid to have the privilege of the user that created the key + 'fleet-apikey-enroll': { + cluster: [], + index: [], + applications: [ + { + application: '.fleet', + privileges: ['no-privileges'], + resources: ['*'], + }, + ], + }, + }, + }, + }) + .catch((err) => { + throw new Error(`Impossible to create an api key: ${err.message}`); + }); if (!key) { throw new Error( diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts index 846e20b48ddca..aa176fe3b188f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts @@ -481,21 +481,84 @@ describe(' serialization', () => { }); }); - test('delete phase', async () => { - const { actions } = testBed; - await actions.delete.enable(true); - await actions.setWaitForSnapshotPolicy('test'); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(entirePolicy.phases.delete).toEqual({ - min_age: '365d', - actions: { - delete: {}, - wait_for_snapshot: { - policy: 'test', + describe('frozen phase', () => { + test('default value', async () => { + const { actions } = testBed; + await actions.frozen.enable(true); + await actions.frozen.setSearchableSnapshot('myRepo'); + + await actions.savePolicy(); + + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.frozen).toEqual({ + min_age: '0d', + actions: { + searchable_snapshot: { snapshot_repository: 'myRepo' }, }, - }, + }); + }); + + describe('deserialization', () => { + beforeEach(async () => { + const policyToEdit = getDefaultHotPhasePolicy('my_policy'); + policyToEdit.policy.phases.frozen = { + min_age: '1234m', + actions: { searchable_snapshot: { snapshot_repository: 'myRepo' } }, + }; + + httpRequestsMockHelpers.setLoadPolicies([policyToEdit]); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + httpRequestsMockHelpers.setListNodes({ + nodesByRoles: {}, + nodesByAttributes: { test: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + test('default value', async () => { + const { actions } = testBed; + + await actions.savePolicy(); + + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.frozen).toEqual({ + min_age: '1234m', + actions: { + searchable_snapshot: { + snapshot_repository: 'myRepo', + }, + }, + }); + }); + }); + }); + + describe('delete phase', () => { + test('default value', async () => { + const { actions } = testBed; + await actions.delete.enable(true); + await actions.setWaitForSnapshotPolicy('test'); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.delete).toEqual({ + min_age: '365d', + actions: { + delete: {}, + wait_for_snapshot: { + policy: 'test', + }, + }, + }); }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index 73ecb0d73b7a7..af571d16ca8c5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -114,6 +114,14 @@ export const createDeserializer = (isCloudEnabled: boolean) => ( } } + if (draft.phases.frozen) { + if (draft.phases.frozen.min_age) { + const minAge = splitSizeAndUnits(draft.phases.frozen.min_age); + draft.phases.frozen.min_age = minAge.size; + draft._meta.frozen.minAgeUnit = minAge.units; + } + } + if (draft.phases.delete) { if (draft.phases.delete.min_age) { const minAge = splitSizeAndUnits(draft.phases.delete.min_age); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index 24dafa6cca237..0b1db784469a9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -267,6 +267,13 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( draft.phases.frozen!.actions = draft.phases.frozen?.actions ?? {}; const frozenPhase = draft.phases.frozen!; + /** + * FROZEN PHASE MIN AGE + */ + if (updatedPolicy.phases.frozen?.min_age) { + frozenPhase.min_age = `${updatedPolicy.phases.frozen!.min_age}${_meta.frozen.minAgeUnit}`; + } + /** * FROZEN PHASE SEARCHABLE SNAPSHOT */ diff --git a/x-pack/plugins/infra/server/kibana.index.ts b/x-pack/plugins/infra/server/kibana.index.ts deleted file mode 100644 index 35d2f845ac4c1..0000000000000 --- a/x-pack/plugins/infra/server/kibana.index.ts +++ /dev/null @@ -1,45 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Server } from '@hapi/hapi'; -import JoiNamespace from 'joi'; - -export interface KbnServer extends Server { - usage: any; -} - -// NP_TODO: this is only used in the root index file AFAICT, can remove after migrating to NP -export const getConfigSchema = (Joi: typeof JoiNamespace) => { - const InfraDefaultSourceConfigSchema = Joi.object({ - metricAlias: Joi.string(), - logAlias: Joi.string(), - fields: Joi.object({ - container: Joi.string(), - host: Joi.string(), - message: Joi.array().items(Joi.string()).single(), - pod: Joi.string(), - tiebreaker: Joi.string(), - timestamp: Joi.string(), - }), - }); - - // NP_TODO: make sure this is all represented in the NP config schema - const InfraRootConfigSchema = Joi.object({ - enabled: Joi.boolean().default(true), - query: Joi.object({ - partitionSize: Joi.number(), - partitionFactor: Joi.number(), - }).default(), - sources: Joi.object() - .keys({ - default: InfraDefaultSourceConfigSchema, - }) - .default(), - }).default(); - - return InfraRootConfigSchema; -}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 5244b8a81e75f..8cee4ea588722 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -33,15 +33,25 @@ type ConditionResult = InventoryMetricConditions & { isError: boolean; }; -export const evaluateCondition = async ( - condition: InventoryMetricConditions, - nodeType: InventoryItemType, - source: InfraSource, - logQueryFields: LogQueryFields, - esClient: ElasticsearchClient, - filterQuery?: string, - lookbackSize?: number -): Promise> => { +export const evaluateCondition = async ({ + condition, + nodeType, + source, + logQueryFields, + esClient, + compositeSize, + filterQuery, + lookbackSize, +}: { + condition: InventoryMetricConditions; + nodeType: InventoryItemType; + source: InfraSource; + logQueryFields: LogQueryFields; + esClient: ElasticsearchClient; + compositeSize: number; + filterQuery?: string; + lookbackSize?: number; +}): Promise> => { const { comparator, warningComparator, metric, customMetric } = condition; let { threshold, warningThreshold } = condition; @@ -61,6 +71,7 @@ export const evaluateCondition = async ( timerange, source, logQueryFields, + compositeSize, filterQuery, customMetric ); @@ -105,6 +116,7 @@ const getData = async ( timerange: InfraTimerangeInput, source: InfraSource, logQueryFields: LogQueryFields, + compositeSize: number, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { @@ -128,7 +140,13 @@ const getData = async ( includeTimeseries: Boolean(timerange.lookbackSize), }; try { - const { nodes } = await getNodes(client, snapshotRequest, source, logQueryFields); + const { nodes } = await getNodes( + client, + snapshotRequest, + source, + logQueryFields, + compositeSize + ); if (!nodes.length) return { [UNGROUPED_FACTORY_KEY]: null }; // No Data state diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index d775a503d1d32..8fb8ee54d22ab 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -73,16 +73,19 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = services.savedObjectsClient ); + const compositeSize = libs.configuration.inventory.compositeSize; + const results = await Promise.all( - criteria.map((c) => - evaluateCondition( - c, + criteria.map((condition) => + evaluateCondition({ + condition, nodeType, source, logQueryFields, - services.scopedClusterClient.asCurrentUser, - filterQuery - ) + esClient: services.scopedClusterClient.asCurrentUser, + compositeSize, + filterQuery, + }) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index f254f1e68ae46..00d01b15750d1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -32,6 +32,7 @@ interface PreviewInventoryMetricThresholdAlertParams { params: InventoryMetricThresholdParams; source: InfraSource; logQueryFields: LogQueryFields; + compositeSize: number; lookback: Unit; alertInterval: string; alertThrottle: string; @@ -46,6 +47,7 @@ export const previewInventoryMetricThresholdAlert: ( params, source, logQueryFields, + compositeSize, lookback, alertInterval, alertThrottle, @@ -70,8 +72,17 @@ export const previewInventoryMetricThresholdAlert: ( try { const results = await Promise.all( - criteria.map((c) => - evaluateCondition(c, nodeType, source, logQueryFields, esClient, filterQuery, lookbackSize) + criteria.map((condition) => + evaluateCondition({ + condition, + nodeType, + source, + logQueryFields, + esClient, + compositeSize, + filterQuery, + lookbackSize, + }) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 9086d6436c2a2..44b2695ba4e3b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -424,9 +424,8 @@ describe('The metric threshold alert type', () => { const createMockStaticConfiguration = (sources: any) => ({ enabled: true, - query: { - partitionSize: 1, - partitionFactor: 1, + inventory: { + compositeSize: 2000, }, sources, }); diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 08e42279e4939..f338d7957a343 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { InfraSourceConfiguration } from '../../common/source_configuration/source_configuration'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; @@ -28,14 +27,3 @@ export interface InfraBackendLibs extends InfraDomainLibs { sourceStatus: InfraSourceStatus; getLogQueryFields: GetLogQueryFields; } - -export interface InfraConfiguration { - enabled: boolean; - query: { - partitionSize: number; - partitionFactor: number; - }; - sources: { - default: InfraSourceConfiguration; - }; -} diff --git a/x-pack/plugins/infra/server/lib/sources/sources.test.ts b/x-pack/plugins/infra/server/lib/sources/sources.test.ts index aaeb44bb03aa7..0786722e5a479 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.test.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.test.ts @@ -134,9 +134,8 @@ describe('the InfraSources lib', () => { const createMockStaticConfiguration = (sources: any) => ({ enabled: true, - query: { - partitionSize: 1, - partitionFactor: 1, + inventory: { + compositeSize: 2000, }, sources, }); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 50fec38b9f2df..f818776fdf82c 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -35,9 +35,8 @@ import { createGetLogQueryFields } from './services/log_queries/get_log_query_fi export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), - query: schema.object({ - partitionSize: schema.number({ defaultValue: 75 }), - partitionFactor: schema.number({ defaultValue: 1.2 }), + inventory: schema.object({ + compositeSize: schema.number({ defaultValue: 2000 }), }), sources: schema.maybe( schema.object({ diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 4d980834d3a70..3008504f3b06c 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -29,6 +29,7 @@ export const initAlertPreviewRoute = ({ framework, sources, getLogQueryFields, + configuration, }: InfraBackendLibs) => { framework.registerRoute( { @@ -56,6 +57,8 @@ export const initAlertPreviewRoute = ({ sourceId || 'default' ); + const compositeSize = configuration.inventory.compositeSize; + try { switch (alertType) { case METRIC_THRESHOLD_ALERT_TYPE_ID: { @@ -96,6 +99,7 @@ export const initAlertPreviewRoute = ({ lookback, source, logQueryFields, + compositeSize, alertInterval, alertThrottle, alertNotifyWhen, diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index cbadd26ccd4bf..c5394a02c1d04 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -40,7 +40,7 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { requestContext.core.savedObjects.client, snapshotRequest.sourceId ); - + const compositeSize = libs.configuration.inventory.compositeSize; const logQueryFields = await libs.getLogQueryFields( snapshotRequest.sourceId, requestContext.core.savedObjects.client @@ -49,7 +49,13 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { UsageCollector.countNode(snapshotRequest.nodeType); const client = createSearchClient(requestContext, framework); - const snapshotResponse = await getNodes(client, snapshotRequest, source, logQueryFields); + const snapshotResponse = await getNodes( + client, + snapshotRequest, + source, + logQueryFields, + compositeSize + ); return response.ok({ body: SnapshotNodeResponseRT.encode(snapshotResponse), diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts index ff3cf048b99de..21420095a3ae5 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts @@ -19,18 +19,26 @@ export interface SourceOverrides { timestamp: string; } -const transformAndQueryData = async ( - client: ESSearchClient, - snapshotRequest: SnapshotRequest, - source: InfraSource, - sourceOverrides?: SourceOverrides -) => { - const metricsApiRequest = await transformRequestToMetricsAPIRequest( +const transformAndQueryData = async ({ + client, + snapshotRequest, + source, + compositeSize, + sourceOverrides, +}: { + client: ESSearchClient; + snapshotRequest: SnapshotRequest; + source: InfraSource; + compositeSize: number; + sourceOverrides?: SourceOverrides; +}) => { + const metricsApiRequest = await transformRequestToMetricsAPIRequest({ client, source, snapshotRequest, - sourceOverrides - ); + compositeSize, + sourceOverrides, + }); const metricsApiResponse = await queryAllData(client, metricsApiRequest); const snapshotResponse = transformMetricsApiResponseToSnapshotResponse( metricsApiRequest, @@ -45,30 +53,39 @@ export const getNodes = async ( client: ESSearchClient, snapshotRequest: SnapshotRequest, source: InfraSource, - logQueryFields: LogQueryFields + logQueryFields: LogQueryFields, + compositeSize: number ) => { let nodes; if (snapshotRequest.metrics.find((metric) => metric.type === 'logRate')) { // *Only* the log rate metric has been requested if (snapshotRequest.metrics.length === 1) { - nodes = await transformAndQueryData(client, snapshotRequest, source, logQueryFields); + nodes = await transformAndQueryData({ + client, + snapshotRequest, + source, + compositeSize, + sourceOverrides: logQueryFields, + }); } else { // A scenario whereby a single host might be shipping metrics and logs. const metricsWithoutLogsMetrics = snapshotRequest.metrics.filter( (metric) => metric.type !== 'logRate' ); - const nodesWithoutLogsMetrics = await transformAndQueryData( + const nodesWithoutLogsMetrics = await transformAndQueryData({ client, - { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, - source - ); - const logRateNodes = await transformAndQueryData( + snapshotRequest: { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, + source, + compositeSize, + }); + const logRateNodes = await transformAndQueryData({ client, - { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, + snapshotRequest: { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, source, - logQueryFields - ); + compositeSize, + sourceOverrides: logQueryFields, + }); // Merge nodes where possible - e.g. a single host is shipping metrics and logs const mergedNodes = nodesWithoutLogsMetrics.nodes.map((node) => { const logRateNode = logRateNodes.nodes.find( @@ -91,7 +108,7 @@ export const getNodes = async ( }; } } else { - nodes = await transformAndQueryData(client, snapshotRequest, source); + nodes = await transformAndQueryData({ client, snapshotRequest, source, compositeSize }); } return nodes; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.ts new file mode 100644 index 0000000000000..1e1c202b7e602 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformRequestToMetricsAPIRequest } from './transform_request_to_metrics_api_request'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { InfraSource } from '../../../lib/sources'; +import { SnapshotRequest } from '../../../../common/http_api'; + +jest.mock('./create_timerange_with_interval', () => { + return { + createTimeRangeWithInterval: () => ({ + interval: '60s', + from: 1605705900000, + to: 1605706200000, + }), + }; +}); + +describe('transformRequestToMetricsAPIRequest', () => { + test('returns a MetricsApiRequest given parameters', async () => { + const compositeSize = 3000; + const result = await transformRequestToMetricsAPIRequest({ + client: {} as ESSearchClient, + source, + snapshotRequest, + compositeSize, + }); + expect(result).toEqual(metricsApiRequest); + }); +}); + +const source: InfraSource = { + id: 'default', + version: 'WzkzNjk5LDVd', + updatedAt: 1617384456384, + origin: 'stored', + configuration: { + name: 'Default', + description: '', + metricAlias: 'metrics-*,metricbeat-*', + logAlias: 'logs-*,filebeat-*,kibana_sample_data_logs*', + fields: { + container: 'container.id', + host: 'host.name', + message: ['message', '@message'], + pod: 'kubernetes.pod.uid', + tiebreaker: '_doc', + timestamp: '@timestamp', + }, + inventoryDefaultView: '0', + metricsExplorerDefaultView: '0', + logColumns: [ + { timestampColumn: { id: '5e7f964a-be8a-40d8-88d2-fbcfbdca0e2f' } }, + { fieldColumn: { id: ' eb9777a8-fcd3-420e-ba7d-172fff6da7a2', field: 'event.dataset' } }, + { messageColumn: { id: 'b645d6da-824b-4723-9a2a-e8cece1645c0' } }, + { fieldColumn: { id: '906175e0-a293-42b2-929f-87a203e6fbec', field: 'agent.name' } }, + ], + anomalyThreshold: 50, + }, +}; + +const snapshotRequest: SnapshotRequest = { + metrics: [{ type: 'cpu' }], + groupBy: [], + nodeType: 'pod', + timerange: { interval: '1m', to: 1605706200000, from: 1605705000000, lookbackSize: 5 }, + filterQuery: '', + sourceId: 'default', + accountId: '', + region: '', + includeTimeseries: true, +}; + +const metricsApiRequest = { + indexPattern: 'metrics-*,metricbeat-*', + timerange: { field: '@timestamp', from: 1605705900000, to: 1605706200000, interval: '60s' }, + metrics: [ + { + id: 'cpu', + aggregations: { + cpu_with_limit: { avg: { field: 'kubernetes.pod.cpu.usage.limit.pct' } }, + cpu_without_limit: { avg: { field: 'kubernetes.pod.cpu.usage.node.pct' } }, + cpu: { + bucket_script: { + buckets_path: { with_limit: 'cpu_with_limit', without_limit: 'cpu_without_limit' }, + script: { + source: 'params.with_limit > 0.0 ? params.with_limit : params.without_limit', + lang: 'painless', + }, + gap_policy: 'skip', + }, + }, + }, + }, + { + id: '__metadata__', + aggregations: { + __metadata__: { + top_metrics: { + metrics: [{ field: 'kubernetes.pod.name' }, { field: 'kubernetes.pod.ip' }], + size: 1, + sort: { '@timestamp': 'desc' }, + }, + }, + }, + }, + ], + limit: 3000, + alignDataToEnd: true, + groupBy: ['kubernetes.pod.uid'], +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts index a71e1fb1f1f14..811b0da952456 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts @@ -15,12 +15,19 @@ import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapsho import { META_KEY } from './constants'; import { SourceOverrides } from './get_nodes'; -export const transformRequestToMetricsAPIRequest = async ( - client: ESSearchClient, - source: InfraSource, - snapshotRequest: SnapshotRequest, - sourceOverrides?: SourceOverrides -): Promise => { +export const transformRequestToMetricsAPIRequest = async ({ + client, + source, + snapshotRequest, + compositeSize, + sourceOverrides, +}: { + client: ESSearchClient; + source: InfraSource; + snapshotRequest: SnapshotRequest; + compositeSize: number; + sourceOverrides?: SourceOverrides; +}): Promise => { const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, { ...snapshotRequest, filterQuery: parseFilterQuery(snapshotRequest.filterQuery), @@ -36,7 +43,9 @@ export const transformRequestToMetricsAPIRequest = async ( interval: timeRangeWithIntervalApplied.interval, }, metrics: transformSnapshotMetricsToMetricsAPIMetrics(snapshotRequest), - limit: snapshotRequest.overrideCompositeSize ? snapshotRequest.overrideCompositeSize : 5, + limit: snapshotRequest.overrideCompositeSize + ? snapshotRequest.overrideCompositeSize + : compositeSize, alignDataToEnd: true, }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index 1e10d650bed2a..cd7c91685467a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -7,7 +7,7 @@ import classNames from 'classnames'; import React, { useState, useEffect, useCallback, memo } from 'react'; -import { EuiFieldText, EuiText, keys } from '@elastic/eui'; +import { EuiFieldText, EuiText, keys, EuiToolTip } from '@elastic/eui'; export interface Props { placeholder: string; @@ -90,11 +90,13 @@ function _InlineTextInput({ tabIndex={disabled ? -1 : 0} onFocus={() => setIsShowingTextInput(true)} > - -
- {text || {placeholder}} -
-
+ + +
+ {text || {placeholder}} +
+
+
); } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss index d51dee31e9f8e..ff78296a60432 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss @@ -38,7 +38,6 @@ overflow-x: hidden; white-space: nowrap; text-overflow: ellipsis; - max-width: 600px; } &__textInput { @@ -60,4 +59,23 @@ // Prevent content jump when spinner renders min-width: 15px; } + + &__controlsContainer { + // Make sure this element knows how wide it is + width: 100%; + // The last element in these controls is an editable text description that can contain an unknown amount (i.e., width) of text. + overflow: hidden; + } + + // By default, flex sets the element width to "auto", we set this explicitly again to avoid the flex item growing beyond the width given + // by flex. This applies to both of the classes below. + &__controlsFlexItem { + min-width: 0; + } + &__descriptionContainer { + min-width: 0; + &--displayNone { + display: none; + } + } } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index 320f99acd08f7..1ba883990ce18 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -67,6 +67,8 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( const isMovingOtherProcessor = editor.mode.id === 'movingProcessor' && !isMovingThisProcessor; const isDimmed = isEditingOtherProcessor || isMovingOtherProcessor; + const processorDescriptor = getProcessorDescriptor(processor.type); + const { testPipelineData } = useTestPipelineContext(); const { config: { selectedDocumentIndex }, @@ -85,10 +87,14 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( 'pipelineProcessorsEditor__item--dimmed': isDimmed, }); - const inlineTextInputContainerClasses = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention - 'pipelineProcessorsEditor__item--displayNone': isInMoveMode && !processor.options.description, - }); + const inlineTextInputContainerClasses = classNames( + 'pipelineProcessorsEditor__item__descriptionContainer', + { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'pipelineProcessorsEditor__item__descriptionContainer--displayNone': + isInMoveMode && !processor.options.description, + } + ); const onDescriptionChange = useCallback( (nextDescription) => { @@ -167,8 +173,13 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( data-test-subj={selectorToDataTestSubject(selector)} data-processor-id={processor.id} > - - + + {renderMoveButton()} {isExecutingPipeline ? ( @@ -193,7 +204,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( }} data-test-subj="manageItemButton" > - {getProcessorDescriptor(processor.type)?.label ?? processor.type} + {processorDescriptor?.label ?? processor.type} @@ -203,7 +214,10 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( onChange={onDescriptionChange} ariaLabel={i18nTexts.processorTypeLabel({ type: processor.type })} text={description} - placeholder={i18nTexts.descriptionPlaceholder} + placeholder={ + processorDescriptor?.getDefaultDescription(processor.options) ?? + i18nTexts.descriptionPlaceholder + } /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx index 25d3e53073041..1160038b52af8 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx @@ -81,7 +81,7 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => const type = typeField.value; const processorDescriptor = getProcessorDescriptor(type); if (processorDescriptor) { - description = processorDescriptor.description || ''; + description = processorDescriptor.typeDescription || ''; selectedOptions = [{ label: processorDescriptor.label, value: type }]; } else { // If there is no label for this processor type, just use the type as the label diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx index 694ae4e07070d..0c8a6ee9c9e29 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx @@ -12,20 +12,17 @@ import { EuiCode } from '@elastic/eui'; import { FIELD_TYPES, - fieldValidators, UseField, Field, ComboBoxField, ToggleField, } from '../../../../../../shared_imports'; -import { FieldsConfig, from, to } from './shared'; +import { FieldsConfig, from, to, isEmptyString } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; -const { emptyField } = fieldValidators; - const fieldsConfig: FieldsConfig = { /* Required fields config */ field_split: { @@ -45,7 +42,7 @@ const fieldsConfig: FieldsConfig = { ), validations: [ { - validator: emptyField( + validator: isEmptyString( i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitRequiredError', { defaultMessage: 'A value is required.', }) @@ -70,7 +67,7 @@ const fieldsConfig: FieldsConfig = { ), validations: [ { - validator: emptyField( + validator: isEmptyString( i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitRequiredError', { defaultMessage: 'A value is required.', }) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts index bafba412c767f..9a45f7f0017c6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts @@ -10,7 +10,9 @@ import * as rt from 'io-ts'; import { i18n } from '@kbn/i18n'; import { isRight } from 'fp-ts/lib/Either'; -import { FieldConfig, ValidationFunc } from '../../../../../../shared_imports'; +import { FieldConfig, ValidationFunc, fieldValidators } from '../../../../../../shared_imports'; + +const { emptyField } = fieldValidators; export const arrayOfStrings = rt.array(rt.string); @@ -118,6 +120,20 @@ export const isJSONStringValidator: ValidationFunc = ({ value }) => { } }; +/** + * Similar to the emptyField validator but we accept whitespace characters. + */ +export const isEmptyString = (message: string): ValidationFunc => (field) => { + const { value } = field; + if (typeof value === 'string') { + const hasLength = Boolean(value.length); + const hasNonWhiteSpaceChars = hasLength && Boolean(value.trim().length); + if (hasNonWhiteSpaceChars) { + return emptyField(message)(field); + } + } +}; + export const EDITOR_PX_HEIGHT = { extraSmall: 75, small: 100, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 9095ab1927cb9..5ab2d68aa193f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -56,7 +56,14 @@ interface FieldDescriptor { * A sentence case label that can be displayed to users */ label: string; - description?: string | ((esDocUrl: string) => ReactNode); + /** + * A general description of the processor type + */ + typeDescription?: string | ((esDocUrl: string) => ReactNode); + /** + * Default + */ + getDefaultDescription: (processorOptions: Record) => string | undefined; } type MapProcessorTypeToDescriptor = Record; @@ -68,10 +75,18 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.append', { defaultMessage: 'Append', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.append', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.append', { defaultMessage: "Appends values to a field's array. If the field contains a single value, the processor first converts it to an array. If the field doesn't exist, the processor creates an array containing the appended values.", }), + getDefaultDescription: ({ field, value }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.append', { + defaultMessage: 'Appends "{value}" to the "{field}" field', + values: { + field, + value, + }, + }), }, bytes: { FieldsComponent: Bytes, @@ -79,10 +94,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.bytes', { defaultMessage: 'Bytes', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.bytes', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.bytes', { defaultMessage: 'Converts digital storage units to bytes. For example, 1KB becomes 1024 bytes.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.bytes', { + defaultMessage: 'Converts "{field}" to its value in bytes', + values: { + field, + }, + }), }, circle: { FieldsComponent: Circle, @@ -90,9 +112,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.circle', { defaultMessage: 'Circle', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.circle', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.circle', { defaultMessage: 'Converts a circle definition into an approximate polygon.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.circle', { + defaultMessage: 'Converts a circle definition of "{field}" into an approximate polygon', + values: { + field, + }, + }), }, convert: { FieldsComponent: Convert, @@ -100,10 +129,18 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.convert', { defaultMessage: 'Convert', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.convert', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.convert', { defaultMessage: 'Converts a field to a different data type. For example, you can convert a string to an long.', }), + getDefaultDescription: ({ field, type }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.convert', { + defaultMessage: 'Converts "{field}" to type "{type}"', + values: { + field, + type, + }, + }), }, csv: { FieldsComponent: CSV, @@ -111,9 +148,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.csv', { defaultMessage: 'CSV', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.csv', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.csv', { defaultMessage: 'Extracts field values from CSV data.', }), + getDefaultDescription: ({ field, target_fields: targetFields }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.csv', { + defaultMessage: 'Extracts CSV values from "{field}" to {target_fields}', + values: { + field, + target_fields: targetFields.map((v: string) => `"${v}"`).join(', '), + }, + }), }, date: { FieldsComponent: DateProcessor, @@ -121,9 +166,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.date', { defaultMessage: 'Date', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.date', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.date', { defaultMessage: 'Converts a date to a document timestamp.', }), + getDefaultDescription: ({ field, target_field: targetField = '@timestamp' }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.date', { + defaultMessage: 'Parses a date from "{field}" to a date type on field "{target_field}"', + values: { + field, + target_field: targetField, + }, + }), }, date_index_name: { FieldsComponent: DateIndexName, @@ -131,13 +184,32 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { defaultMessage: 'Date index name', }), - description: () => ( + typeDescription: () => ( {'my-index-yyyy-MM-dd'} }} /> ), + getDefaultDescription: ({ field, index_name_prefix: indexNamePrefix }) => { + const prefix = indexNamePrefix + ? i18n.translate( + 'xpack.ingestPipelines.processors.defaultDescription.dateIndexName.indexNamePrefixDefault.prefixValueLabel', + { defaultMessage: 'with the prefix "{prefix}"', values: { prefix: indexNamePrefix } } + ) + : i18n.translate( + 'xpack.ingestPipelines.processors.defaultDescription.dateIndexName.indexNamePrefixDefault.noPrefixValueLabel', + { defaultMessage: 'with no prefix' } + ); + return i18n.translate('xpack.ingestPipelines.processors.defaultDescription.date_index_name', { + defaultMessage: + 'Adds documents to a time-based index based on the timestamp value in "{field}", {prefix}', + values: { + field, + prefix, + }, + }); + }, }, dissect: { FieldsComponent: Dissect, @@ -145,9 +217,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dissect', { defaultMessage: 'Dissect', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.dissect', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.dissect', { defaultMessage: 'Uses dissect patterns to extract matches from a field.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.dissect', { + defaultMessage: 'Extracts values from "{field}" that match a dissect pattern', + values: { + field, + }, + }), }, dot_expander: { FieldsComponent: DotExpander, @@ -155,10 +234,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { defaultMessage: 'Dot expander', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.dotExpander', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.dotExpander', { defaultMessage: 'Expands a field containing dot notation into an object field. The object field is then accessible by other processors in the pipeline.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.dot_expander', { + defaultMessage: 'Expands "{field}" into an object field', + values: { + field, + }, + }), }, drop: { FieldsComponent: Drop, @@ -166,10 +252,13 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.drop', { defaultMessage: 'Drop', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.drop', { - defaultMessage: - 'Drops documents without returning an error. Used to only index documents that meet specified conditions.', + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.drop', { + defaultMessage: 'Drops documents without returning an error.', }), + getDefaultDescription: () => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.drop', { + defaultMessage: 'Drops documents without returning an error', + }), }, enrich: { FieldsComponent: Enrich, @@ -177,7 +266,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.enrich', { defaultMessage: 'Enrich', }), - description: (esDocUrl) => { + typeDescription: (esDocUrl) => { return ( ); }, + getDefaultDescription: ({ field, policy_name: policyName, target_field: targetField }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.enrich', { + defaultMessage: + 'Enriches data to "{target_field}" if the "{policy_name}" policy matches "{field}"', + values: { + field, + policy_name: policyName, + target_field: targetField, + }, + }), }, fail: { FieldsComponent: Fail, @@ -199,10 +298,14 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.fail', { defaultMessage: 'Fail', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.fail', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.fail', { defaultMessage: 'Returns a custom error message on failure. Often used to notify requesters of required conditions.', }), + getDefaultDescription: () => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.fail', { + defaultMessage: 'Raises an exception that halts execution', + }), }, foreach: { FieldsComponent: Foreach, @@ -210,9 +313,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.foreach', { defaultMessage: 'Foreach', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.foreach', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.foreach', { defaultMessage: 'Applies an ingest processor to each value in an array.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.foreach', { + defaultMessage: 'Runs a processor for each object in "{field}"', + values: { + field, + }, + }), }, geoip: { FieldsComponent: GeoIP, @@ -220,10 +330,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.geoip', { defaultMessage: 'GeoIP', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.geoip', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.geoip', { defaultMessage: 'Adds geo data based on an IP address. Uses geo data from a Maxmind database file.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.geoip', { + defaultMessage: 'Adds geo data to documents based on the value of "{field}"', + values: { + field, + }, + }), }, grok: { FieldsComponent: Grok, @@ -231,7 +348,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.grok', { defaultMessage: 'Grok', }), - description: (esDocUrl) => { + typeDescription: (esDocUrl) => { return ( ); }, + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.grok', { + defaultMessage: 'Extracts values from "{field}" that match a grok pattern', + values: { + field, + }, + }), }, gsub: { FieldsComponent: Gsub, @@ -253,9 +377,18 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.gsub', { defaultMessage: 'Gsub', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.gsub', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.gsub', { defaultMessage: 'Uses a regular expression to replace field substrings.', }), + getDefaultDescription: ({ pattern, field, replacement }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.gsub', { + defaultMessage: 'Replaces values matching "{pattern}" in "{field}" with "{replacement}"', + values: { + pattern, + field, + replacement, + }, + }), }, html_strip: { FieldsComponent: HtmlStrip, @@ -263,9 +396,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.htmlStrip', { defaultMessage: 'HTML strip', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.htmlStrip', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.htmlStrip', { defaultMessage: 'Removes HTML tags from a field.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.html_strip', { + defaultMessage: 'Removes HTML tags from "{field}"', + values: { + field, + }, + }), }, inference: { FieldsComponent: Inference, @@ -273,10 +413,21 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.inference', { defaultMessage: 'Inference', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.inference', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.inference', { defaultMessage: 'Uses a pre-trained data frame analytics model to infer against incoming data.', }), + getDefaultDescription: ({ + model_id: modelId, + target_field: targetField = 'ml.inference.', + }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.inference', { + defaultMessage: 'Runs the model "{modelId}" and stores the result in "{target_field}"', + values: { + modelId, + target_field: targetField, + }, + }), }, join: { FieldsComponent: Join, @@ -284,10 +435,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.join', { defaultMessage: 'Join', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.join', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.join', { defaultMessage: 'Joins array elements into a string. Inserts a separator between each element.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.join', { + defaultMessage: 'Joins each element of the array stored in "{field}"', + values: { + field, + }, + }), }, json: { FieldsComponent: Json, @@ -295,9 +453,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.json', { defaultMessage: 'JSON', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.json', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.json', { defaultMessage: 'Creates a JSON object from a compatible string.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.json', { + defaultMessage: 'Parses "{field}" to create a JSON object from a string', + values: { + field, + }, + }), }, kv: { FieldsComponent: Kv, @@ -305,9 +470,19 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.kv', { defaultMessage: 'Key-value (KV)', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.kv', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.kv', { defaultMessage: 'Extracts fields from a string containing key-value pairs.', }), + getDefaultDescription: ({ field, field_split: fieldSplit, value_split: valueSplit }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.kv', { + defaultMessage: + 'Extracts key-value pairs from "{field}" and splits on "{field_split}" and "{value_split}"', + values: { + field, + field_split: fieldSplit, + value_split: valueSplit, + }, + }), }, lowercase: { FieldsComponent: Lowercase, @@ -315,9 +490,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', { defaultMessage: 'Lowercase', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.lowercase', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.lowercase', { defaultMessage: 'Converts a string to lowercase.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.lowercase', { + defaultMessage: 'Converts values in "{field}" to lowercase', + values: { + field, + }, + }), }, pipeline: { FieldsComponent: Pipeline, @@ -325,9 +507,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', { defaultMessage: 'Pipeline', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.pipeline', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.pipeline', { defaultMessage: 'Runs another ingest node pipeline.', }), + getDefaultDescription: ({ name }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.pipeline', { + defaultMessage: 'Runs the "{name}" ingest pipeline', + values: { + name, + }, + }), }, remove: { FieldsComponent: Remove, @@ -335,9 +524,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.remove', { defaultMessage: 'Remove', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.remove', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.remove', { defaultMessage: 'Removes one or more fields.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.remove', { + defaultMessage: 'Removes "{field}"', + values: { + field: Array.isArray(field) ? field.map((v) => `"${v}"`).join(', ') : field, + }, + }), }, rename: { FieldsComponent: Rename, @@ -345,9 +541,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.rename', { defaultMessage: 'Rename', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.rename', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.rename', { defaultMessage: 'Renames an existing field.', }), + getDefaultDescription: ({ field, target_field: targetField }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.rename', { + defaultMessage: 'Renames "{field}" to "{target_field}"', + values: { + field, + target_field: targetField, + }, + }), }, script: { FieldsComponent: Script, @@ -355,9 +559,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.script', { defaultMessage: 'Script', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.script', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.script', { defaultMessage: 'Runs a script on incoming documents.', }), + getDefaultDescription: () => 'Runs a script on incoming documents', }, set: { FieldsComponent: SetProcessor, @@ -365,9 +570,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.set', { defaultMessage: 'Set', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.set', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.set', { defaultMessage: 'Sets the value of a field.', }), + getDefaultDescription: ({ field, value }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.set', { + defaultMessage: 'Sets value of "{field}" to "{value}"', + values: { + field, + value, + }, + }), }, set_security_user: { FieldsComponent: SetSecurityUser, @@ -375,10 +588,18 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { defaultMessage: 'Set security user', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.setSecurityUser', { - defaultMessage: - 'Adds details about the current user, such user name and email address, to incoming documents. Requires an authenticated user for the indexing request.', - }), + typeDescription: i18n.translate( + 'xpack.ingestPipelines.processors.description.setSecurityUser', + { + defaultMessage: + 'Adds details about the current user, such user name and email address, to incoming documents. Requires an authenticated user for the indexing request.', + } + ), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.setSecurityUser', { + defaultMessage: 'Adds details about the current user to "{field}"', + values: { field }, + }), }, sort: { FieldsComponent: Sort, @@ -386,9 +607,26 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.sort', { defaultMessage: 'Sort', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.sort', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.sort', { defaultMessage: "Sorts a field's array elements.", }), + getDefaultDescription: ({ field, order = 'asc' }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.sort', { + defaultMessage: 'Sorts elements in the array "{field}" in {order} order', + values: { + field, + order: + order === 'asc' + ? i18n.translate( + 'xpack.ingestPipelines.processors.defaultDescription.sort.orderAscendingLabel', + { defaultMessage: 'ascending' } + ) + : i18n.translate( + 'xpack.ingestPipelines.processors.defaultDescription.sort.orderDescendingLabel', + { defaultMessage: 'descending' } + ), + }, + }), }, split: { FieldsComponent: Split, @@ -396,9 +634,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.split', { defaultMessage: 'Split', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.split', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.split', { defaultMessage: 'Splits a field value into an array.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.split', { + defaultMessage: 'Splits the string stored in "{field}" to an array', + values: { + field, + }, + }), }, trim: { FieldsComponent: Trim, @@ -406,9 +651,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.trim', { defaultMessage: 'Trim', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.trim', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.trim', { defaultMessage: 'Removes leading and trailing whitespace from a string.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.trim', { + defaultMessage: 'Trims whitespaces from "{field}"', + values: { + field, + }, + }), }, uppercase: { FieldsComponent: Uppercase, @@ -416,9 +668,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.uppercase', { defaultMessage: 'Uppercase', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.uppercase', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.uppercase', { defaultMessage: 'Converts a string to uppercase.', }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.uppercase', { + defaultMessage: 'Converts values in "{field}" to uppercase', + values: { + field, + }, + }), }, urldecode: { FieldsComponent: UrlDecode, @@ -426,19 +685,16 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.urldecode', { defaultMessage: 'URL decode', }), - description: i18n.translate('xpack.ingestPipelines.processors.description.urldecode', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.urldecode', { defaultMessage: 'Decodes a URL-encoded string.', }), - }, - user_agent: { - FieldsComponent: UserAgent, - docLinkPath: '/user-agent-processor.html', - label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { - defaultMessage: 'User agent', - }), - description: i18n.translate('xpack.ingestPipelines.processors.description.userAgent', { - defaultMessage: "Extracts values from a browser's user agent string.", - }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.url_decode', { + defaultMessage: 'Decodes the URL in "{field}"', + values: { + field, + }, + }), }, uri_parts: { FieldsComponent: UriParts, @@ -446,10 +702,38 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.uriPartsLabel', { defaultMessage: 'URI parts', }), - description: i18n.translate('xpack.ingestPipelines.processors.uriPartsDescription', { + typeDescription: i18n.translate('xpack.ingestPipelines.processors.uriPartsDescription', { defaultMessage: 'Parses a Uniform Resource Identifier (URI) string and extracts its components as an object.', }), + getDefaultDescription: ({ field, target_field: targetField = 'url' }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.uri_parts', { + defaultMessage: + 'Parses the URI string in "{field}" and stores the result in "{target_field}"', + values: { + field, + target_field: targetField, + }, + }), + }, + user_agent: { + FieldsComponent: UserAgent, + docLinkPath: '/user-agent-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { + defaultMessage: 'User agent', + }), + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.userAgent', { + defaultMessage: "Extracts values from a browser's user agent string.", + }), + getDefaultDescription: ({ field, target_field: targetField = 'user_agent' }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.user_agent', { + defaultMessage: + 'Extracts the user agent from "{field}" and stores the results in "{target_field}"', + values: { + field, + target_field: targetField, + }, + }), }, }; diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 20bf349f6b13a..b7dbf1bbe4d87 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -155,11 +155,7 @@ function createMockTimefilter() { getBounds: jest.fn(() => timeFilter), getRefreshInterval: () => {}, getRefreshIntervalDefaults: () => {}, - getAutoRefreshFetch$: () => ({ - subscribe: ({ next }: { next: () => void }) => { - return next; - }, - }), + getAutoRefreshFetch$: () => new Observable(), }; } diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index dbc10c751a649..39163101fc7bd 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -14,6 +14,7 @@ import { Toast } from 'kibana/public'; import { VisualizeFieldContext } from 'src/plugins/ui_actions/public'; import { Datatable } from 'src/plugins/expressions/public'; import { EuiBreadcrumb } from '@elastic/eui'; +import { finalize, switchMap, tap } from 'rxjs/operators'; import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { createKbnUrlStateStorage, @@ -37,6 +38,7 @@ import { Query, SavedQuery, syncQueryStateWithUrl, + waitUntilNextSessionCompletes$, } from '../../../../../src/plugins/data/public'; import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common'; import { LensAppProps, LensAppServices, LensAppState } from './types'; @@ -193,14 +195,19 @@ export function App({ const autoRefreshSubscription = data.query.timefilter.timefilter .getAutoRefreshFetch$() - .subscribe({ - next: () => { + .pipe( + tap(() => { setState((s) => ({ ...s, searchSessionId: data.search.session.start(), })); - }, - }); + }), + switchMap((done) => + // best way in lens to estimate that all panels are updated is to rely on search session service state + waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done)) + ) + ) + .subscribe(); const kbnUrlStateStorage = createKbnUrlStateStorage({ history, diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 4094ecee74e1c..f8b56f4ff2f81 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -60,6 +60,7 @@ export const datatableVisualization: Visualization groupLabel: i18n.translate('xpack.lens.datatable.groupLabel', { defaultMessage: 'Tabular and single value', }), + sortPriority: 1, }, ], diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index ef8c0798bb91e..5538dd26d0323 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -219,12 +219,15 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { // reorganize visualizations in groups const grouped: Record< string, - Array< - VisualizationType & { - visualizationId: string; - selection: VisualizationSelection; - } - > + { + priority: number; + visualizations: Array< + VisualizationType & { + visualizationId: string; + selection: VisualizationSelection; + } + >; + } > = {}; // Will need it later on to quickly pick up the metadata from it const lookup: Record< @@ -240,13 +243,17 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { visualizationType.label.toLowerCase().includes(lowercasedSearchTerm) || visualizationType.fullLabel?.toLowerCase().includes(lowercasedSearchTerm); if (isSearchMatch) { - grouped[visualizationType.groupLabel] = grouped[visualizationType.groupLabel] || []; + grouped[visualizationType.groupLabel] = grouped[visualizationType.groupLabel] || { + priority: 0, + visualizations: [], + }; const visualizationEntry = { ...visualizationType, visualizationId, selection: getSelection(visualizationId, visualizationType.id), }; - grouped[visualizationType.groupLabel].push(visualizationEntry); + grouped[visualizationType.groupLabel].priority += visualizationType.sortPriority || 0; + grouped[visualizationType.groupLabel].visualizations.push(visualizationEntry); lookup[`${visualizationId}:${visualizationType.id}`] = visualizationEntry; } } @@ -254,9 +261,11 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { return { visualizationTypes: Object.keys(grouped) - .sort() + .sort((groupA, groupB) => { + return grouped[groupB].priority - grouped[groupA].priority; + }) .flatMap((group): SelectableEntry[] => { - const visualizations = grouped[group]; + const { visualizations } = grouped[group]; if (visualizations.length === 0) { return []; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 8a0b9922c736b..f9058b48dd1a8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -570,7 +570,7 @@ export const InnerVisualizationWrapper = ({ { setLocalState((prevState: WorkspaceState) => ({ diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index cedb648215c0e..fcfed9b9f1fc5 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -33,6 +33,7 @@ export type { IndexPatternPersistedState, PersistedIndexPatternLayer, IndexPatternColumn, + FieldBasedIndexPatternColumn, OperationType, IncompleteColumn, FiltersIndexPatternColumn, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 79155184a5f6d..18f653c588ee8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -11,6 +11,7 @@ import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/pub import { DragDropIdentifier } from '../drag_drop/providers'; export { + FieldBasedIndexPatternColumn, IndexPatternColumn, OperationType, IncompleteColumn, diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 34b9e4d2b2526..e0977be7535af 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -55,6 +55,7 @@ export const metricVisualization: Visualization = { groupLabel: i18n.translate('xpack.lens.metric.groupLabel', { defaultMessage: 'Tabular and single value', }), + sortPriority: 1, }, ], diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 3d34d22c5048a..94b4433a82551 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -550,6 +550,11 @@ export interface VisualizationType { * The group the visualization belongs to */ groupLabel: string; + /** + * The priority of the visualization in the list (global priority) + * Higher number means higher priority. When omitted defaults to 0 + */ + sortPriority?: number; } export interface Visualization { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 7910e931e60e6..d601e4fa9a39a 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -436,7 +436,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle null // needs to be stripped server-side ); - const dsl = await searchSource.getSearchRequestBody(); + const dsl = searchSource.getSearchRequestBody(); const risonDsl = rison.encode(dsl); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index ac3a15d2ac490..82d7b09e9c3f2 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -706,7 +706,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource.setField('sort', this._buildEsSort()); } - const dsl = await searchSource.getSearchRequestBody(); + const dsl = searchSource.getSearchRequestBody(); const risonDsl = rison.encode(dsl); const mvtUrlServicePath = getHttp().basePath.prepend( diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 222c49abfa16a..2915eaec8ac77 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -19,7 +19,6 @@ import { createExtentFilter } from '../../../../common/elasticsearch_util'; import { copyPersistentState } from '../../../reducers/copy_persistent_state'; import { DataRequestAbortError } from '../../util/data_request'; import { expandToTileBoundaries } from '../../../../common/geo_tile_utils'; -import { search } from '../../../../../../../src/plugins/data/public'; import { IVectorSource } from '../vector_source'; import { TimeRange } from '../../../../../../../src/plugins/data/common'; import { @@ -35,10 +34,7 @@ import { IVectorStyle } from '../../styles/vector/vector_style'; import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; import { IField } from '../../fields/field'; import { FieldFormatter } from '../../../../common/constants'; -import { - Adapters, - RequestResponder, -} from '../../../../../../../src/plugins/inspector/common/adapters'; +import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { isValidStringConfig } from '../../util/valid_string_config'; export function isSearchSourceAbortError(error: Error) { @@ -171,40 +167,23 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const inspectorAdapters = this.getInspectorAdapters(); - let inspectorRequest: RequestResponder | undefined; - if (inspectorAdapters?.requests) { - inspectorRequest = inspectorAdapters.requests.start(requestName, { - id: requestId, - description: requestDescription, - searchSessionId, - }); - } + const requestResponder = this.getInspectorAdapters()?.requests?.start(requestName, { + id: requestId, + description: requestDescription, + searchSessionId, + }); let resp; try { - if (inspectorRequest) { - const requestStats = search.getRequestInspectorStats(searchSource); - inspectorRequest.stats(requestStats); - searchSource.getSearchRequestBody().then((body) => { - if (inspectorRequest) { - inspectorRequest.json(body); - } - }); - } - resp = await searchSource.fetch({ - abortSignal: abortController.signal, - sessionId: searchSessionId, - legacyHitsTotal: false, - }); - if (inspectorRequest) { - const responseStats = search.getResponseInspectorStats(resp, searchSource); - inspectorRequest.stats(responseStats).ok({ json: resp }); - } + resp = await searchSource + .fetch$({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + legacyHitsTotal: false, + requestResponder, + }) + .toPromise(); } catch (error) { - if (inspectorRequest) { - inspectorRequest.error(error); - } if (isSearchSourceAbortError(error)) { throw new DataRequestAbortError(); } diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index c15aa8f414fb1..a64a0c0ae09fe 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -10,6 +10,7 @@ export { ChartData } from './types/field_histograms'; export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies'; export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; export { isPopulatedObject } from './util/object_utils'; -export { isRuntimeMappings } from './util/runtime_field_utils'; export { composeValidators, patternValidator } from './util/validators'; +export { isRuntimeMappings, isRuntimeField } from './util/runtime_field_utils'; export { extractErrorMessage } from './util/errors'; +export type { RuntimeMappings } from './types/fields'; diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index 8dfe9d111ed38..45fcfac7e930c 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -28,7 +28,7 @@ export interface Field { aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; - runtimeField?: RuntimeField; + runtimeField?: estypes.RuntimeField; } export interface Aggregation { @@ -108,17 +108,4 @@ export interface AggCardinality { export type RollupFields = Record]>; -// Replace this with import once #88995 is merged -export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; -export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; - -export interface RuntimeField { - type: RuntimeType; - script?: - | string - | { - source: string; - }; -} - export type RuntimeMappings = estypes.RuntimeFields; diff --git a/x-pack/plugins/ml/common/util/runtime_field_utils.ts b/x-pack/plugins/ml/common/util/runtime_field_utils.ts index 6d911ecd5d3cb..7be2a3ec8c9e1 100644 --- a/x-pack/plugins/ml/common/util/runtime_field_utils.ts +++ b/x-pack/plugins/ml/common/util/runtime_field_utils.ts @@ -4,14 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { estypes } from '@elastic/elasticsearch'; import { isPopulatedObject } from './object_utils'; import { RUNTIME_FIELD_TYPES } from '../../../../../src/plugins/data/common'; -import type { RuntimeField, RuntimeMappings } from '../types/fields'; +import type { RuntimeMappings } from '../types/fields'; type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; -export function isRuntimeField(arg: unknown): arg is RuntimeField { +export function isRuntimeField(arg: unknown): arg is estypes.RuntimeField { return ( ((isPopulatedObject(arg, ['type']) && Object.keys(arg).length === 1) || (isPopulatedObject(arg, ['type', 'script']) && diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index d3e58c4d7bb0d..f723c1d72b818 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from 'src/core/public'; +import type { estypes } from '@elastic/elasticsearch'; import { IndexPattern, IFieldType, @@ -49,7 +50,7 @@ import { getNestedProperty } from '../../util/object_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; import { DataGridItem, IndexPagination, RenderCellValue } from './types'; -import { RuntimeMappings, RuntimeField } from '../../../../common/types/fields'; +import { RuntimeMappings } from '../../../../common/types/fields'; import { isRuntimeMappings } from '../../../../common/util/runtime_field_utils'; export const INIT_MAX_COLUMNS = 10; @@ -179,7 +180,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results export const NON_AGGREGATABLE = 'non-aggregatable'; export const getDataGridSchemaFromESFieldType = ( - fieldType: ES_FIELD_TYPES | undefined | RuntimeField['type'] + fieldType: ES_FIELD_TYPES | undefined | estypes.RuntimeField['type'] ): string | undefined => { // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] // To fall back to the default string schema it needs to be undefined. diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx index df65540445d94..cc7c4cbd15a0b 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx @@ -35,7 +35,20 @@ jest.mock('../../../contexts/kibana', () => ({ useMlKibana: () => { return { services: { - uiSettings: { get: jest.fn() }, + uiSettings: { + get: jest.fn().mockReturnValue([ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + ]), + }, data: { query: { timefilter: { diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index 8df5b5f6ec667..c6f84351bdc10 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -5,17 +5,24 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import { debounce } from 'lodash'; import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; import { TimeHistoryContract, TimeRange } from 'src/plugins/data/public'; +import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/common'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; import { useUrlState } from '../../../util/url_state'; import { useMlKibana } from '../../../contexts/kibana'; +interface TimePickerQuickRange { + from: string; + to: string; + display: string; +} + interface Duration { start: string; end: string; @@ -71,6 +78,19 @@ export const DatePickerWrapper: FC = () => { ); const dateFormat = config.get('dateFormat'); + const timePickerQuickRanges = config.get( + UI_SETTINGS.TIMEPICKER_QUICK_RANGES + ); + + const commonlyUsedRanges = useMemo( + () => + timePickerQuickRanges.map(({ from, to, display }) => ({ + start: from, + end: to, + label: display, + })), + [timePickerQuickRanges] + ); useEffect(() => { const subscriptions = new Subscription(); @@ -141,6 +161,7 @@ export const DatePickerWrapper: FC = () => { onRefreshChange={updateInterval} recentlyUsedRanges={recentlyUsedRanges} dateFormat={dateFormat} + commonlyUsedRanges={commonlyUsedRanges} />
) : null; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts index a1d846c065dce..4c12d05c1f2b7 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; + export const kibanaContextMock = { services: { + uiSettings: { get: jest.fn() }, chrome: { recentlyAccessed: { add: jest.fn() } }, application: { navigateToApp: jest.fn() }, http: { @@ -17,6 +20,7 @@ export const kibanaContextMock = { share: { urlGenerators: { getUrlGenerator: jest.fn() }, }, + data: dataPluginMock.createStartContract(), }, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts index 1e1f376049579..79986e8ddb098 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts @@ -6,8 +6,8 @@ */ import { i18n } from '@kbn/i18n'; +import { estypes } from '@elastic/elasticsearch'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { RuntimeType } from '../../../../../../../../../../src/plugins/data/common'; import { EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; @@ -18,7 +18,7 @@ export const CATEGORICAL_TYPES = new Set(['ip', 'keyword']); // Regression supports numeric fields. Classification supports categorical, numeric, and boolean. export const shouldAddAsDepVarOption = ( fieldId: string, - fieldType: ES_FIELD_TYPES | RuntimeType, + fieldType: ES_FIELD_TYPES | estypes.RuntimeField['type'], jobType: AnalyticsJobType ) => { if (fieldId === EVENT_RATE_FIELD_ID) return false; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx index d21bf67a1f51c..5b8fc82ef587b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx @@ -131,7 +131,7 @@ export const RuntimeMappings: FC = ({ actions, state }) => { defaultMessage: 'Runtime mappings', })} > - + {isPopulatedObject(runtimeMappings) ? ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index f48f4a62f5a7d..2d9ae1cd4689b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -13,7 +13,7 @@ import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { isRuntimeMappings } from '../../../../../../common/util/runtime_field_utils'; -import { RuntimeMappings, RuntimeField } from '../../../../../../common/types/fields'; +import { RuntimeMappings } from '../../../../../../common/types/fields'; import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../../common/constants/field_histograms'; import { DataLoader } from '../../../../datavisualizer/index_based/data_loader'; @@ -44,7 +44,7 @@ interface MLEuiDataGridColumn extends EuiDataGridColumn { function getRuntimeFieldColumns(runtimeMappings: RuntimeMappings) { return Object.keys(runtimeMappings).map((id) => { const field = runtimeMappings[id]; - const schema = getDataGridSchemaFromESFieldType(field.type as RuntimeField['type']); + const schema = getDataGridSchemaFromESFieldType(field.type as estypes.RuntimeField['type']); return { id, schema, isExpandable: schema !== 'boolean', isRuntimeFieldColumn: true }; }); } @@ -64,7 +64,7 @@ export const useIndexData = ( const field = indexPattern.fields.getByName(id); const isRuntimeFieldColumn = field?.runtimeField !== undefined; const schema = isRuntimeFieldColumn - ? getDataGridSchemaFromESFieldType(field?.type as RuntimeField['type']) + ? getDataGridSchemaFromESFieldType(field?.type as estypes.RuntimeField['type']) : getDataGridSchemaFromKibanaFieldType(field); return { id, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx new file mode 100644 index 0000000000000..858ab58b53f4b --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; + +import { IntlProvider } from 'react-intl'; + +import { + getIndexPatternAndSavedSearch, + IndexPatternAndSavedSearch, +} from '../../../../../util/index_utils'; + +import { SourceSelection } from './source_selection'; + +jest.mock('../../../../../../../../../../src/plugins/saved_objects/public', () => { + const SavedObjectFinderUi = ({ + onChoose, + }: { + onChoose: (id: string, type: string, fullName: string, savedObject: object) => void; + }) => { + return ( + <> + + + + + + ); + }; + + return { + SavedObjectFinderUi, + }; +}); + +const mockNavigateToPath = jest.fn(); +jest.mock('../../../../../contexts/kibana', () => ({ + useMlKibana: () => ({ + services: { + savedObjects: {}, + uiSettings: {}, + }, + }), + useNavigateToPath: () => mockNavigateToPath, +})); + +jest.mock('../../../../../util/index_utils', () => { + return { + getIndexPatternAndSavedSearch: jest.fn( + async (id: string): Promise => { + return { + indexPattern: { + fields: [], + title: + id === 'the-remote-saved-search-id' + ? 'my_remote_cluster:index-pattern-title' + : 'index-pattern-title', + }, + savedSearch: null, + }; + } + ), + }; +}); + +const mockOnClose = jest.fn(); +const mockGetIndexPatternAndSavedSearch = getIndexPatternAndSavedSearch as jest.Mock; + +describe('Data Frame Analytics: ', () => { + afterEach(() => { + mockNavigateToPath.mockClear(); + mockGetIndexPatternAndSavedSearch.mockClear(); + }); + + it('renders the title text', async () => { + // prepare + render( + + + + ); + + // assert + expect(screen.queryByText('New analytics job')).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + + it('shows the error callout when clicking a remote index pattern', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('RemoteIndexPattern', { selector: 'button' })); + await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut')); + + // assert + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + + it('calls navigateToPath for a plain index pattern ', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('PlainIndexPattern', { selector: 'button' })); + + // assert + await waitFor(() => { + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).not.toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledWith( + '/data_frame_analytics/new_job?index=the-plain-index-pattern-id' + ); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + }); + + it('shows the error callout when clicking a saved search using a remote index pattern', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('RemoteSavedSearch', { selector: 'button' })); + await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut')); + + // assert + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).toBeInTheDocument(); + expect( + screen.queryByText( + `The saved search 'the-remote-saved-search-title' uses the index pattern 'my_remote_cluster:index-pattern-title'.` + ) + ).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-remote-saved-search-id'); + }); + + it('calls navigateToPath for a saved search using a plain index pattern ', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('PlainSavedSearch', { selector: 'button' })); + + // assert + await waitFor(() => { + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).not.toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledWith( + '/data_frame_analytics/new_job?savedSearchId=the-plain-saved-search-id' + ); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-plain-saved-search-id'); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx index 40f97690d7790..cbc5a226eb319 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx @@ -5,15 +5,28 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { useState, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; +import { + EuiCallOut, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, +} from '@elastic/eui'; + +import type { SimpleSavedObject } from 'src/core/public'; import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public'; import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana'; +import { getNestedProperty } from '../../../../../util/object_utils'; + +import { getIndexPatternAndSavedSearch } from '../../../../../util/index_utils'; + const fixedPageSize: number = 8; interface Props { @@ -26,7 +39,49 @@ export const SourceSelection: FC = ({ onClose }) => { } = useMlKibana(); const navigateToPath = useNavigateToPath(); - const onSearchSelected = async (id: string, type: string) => { + const [isCcsCallOut, setIsCcsCallOut] = useState(false); + const [ccsCallOutBodyText, setCcsCallOutBodyText] = useState(); + + const onSearchSelected = async ( + id: string, + type: string, + fullName: string, + savedObject: SimpleSavedObject + ) => { + // Kibana index patterns including `:` are cross-cluster search indices + // and are not supported by Data Frame Analytics yet. For saved searches + // and index patterns that use cross-cluster search we intercept + // the selection before redirecting and show an error callout instead. + let indexPatternTitle = ''; + + if (type === 'index-pattern') { + indexPatternTitle = getNestedProperty(savedObject, 'attributes.title'); + } else if (type === 'search') { + const indexPatternAndSavedSearch = await getIndexPatternAndSavedSearch(id); + indexPatternTitle = indexPatternAndSavedSearch.indexPattern?.title ?? ''; + } + + if (indexPatternTitle.includes(':')) { + setIsCcsCallOut(true); + if (type === 'search') { + setCcsCallOutBodyText( + i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.CcsErrorCallOutBody', + { + defaultMessage: `The saved search '{savedSearchTitle}' uses the index pattern '{indexPatternTitle}'.`, + values: { + savedSearchTitle: getNestedProperty(savedObject, 'attributes.title'), + indexPatternTitle, + }, + } + ) + ); + } else { + setCcsCallOutBodyText(undefined); + } + return; + } + await navigateToPath( `/data_frame_analytics/new_job?${ type === 'index-pattern' ? 'index' : 'savedSearchId' @@ -54,6 +109,23 @@ export const SourceSelection: FC = ({ onClose }) => { + {isCcsCallOut && ( + <> + + {typeof ccsCallOutBodyText === 'string' &&

{ccsCallOutBodyText}

} +
+ + + )} any>(func: T, context?: any) => { const memoizedLoadAnnotationsTableData = memoize( loadAnnotationsTableData ); -const memoizedLoadDataForCharts = memoize(loadDataForCharts); const memoizedLoadFilteredTopInfluencers = memoize( loadFilteredTopInfluencers ); @@ -96,7 +95,7 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi const loadExplorerDataProvider = ( mlResultsService: MlResultsService, anomalyTimelineService: AnomalyTimelineService, - anomalyExplorerService: AnomalyExplorerChartsService, + anomalyExplorerChartsService: AnomalyExplorerChartsService, timefilter: TimefilterContract ) => { const memoizedLoadOverallData = memoize( @@ -108,8 +107,8 @@ const loadExplorerDataProvider = ( anomalyTimelineService ); const memoizedAnomalyDataChange = memoize( - anomalyExplorerService.getAnomalyData, - anomalyExplorerService + anomalyExplorerChartsService.getAnomalyData, + anomalyExplorerChartsService ); return (config: LoadExplorerDataConfig): Observable> => { @@ -160,9 +159,7 @@ const loadExplorerDataProvider = ( swimlaneBucketInterval.asSeconds(), bounds ), - anomalyChartRecords: memoizedLoadDataForCharts( - lastRefresh, - mlResultsService, + anomalyChartRecords: anomalyExplorerChartsService.loadDataForCharts$( jobIds, timerange.earliestMs, timerange.latestMs, @@ -214,42 +211,30 @@ const loadExplorerDataProvider = ( // show the view-by loading indicator // and pass on the data we already fetched. tap(explorerService.setViewBySwimlaneLoading), - // Trigger a side-effect to update the charts. - tap(({ anomalyChartRecords, topFieldValues }) => { - if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { - memoizedAnomalyDataChange( - lastRefresh, - explorerService, - combinedJobRecords, - swimlaneContainerWidth, - anomalyChartRecords, - timerange.earliestMs, - timerange.latestMs, - timefilter, - tableSeverity - ); - } else { - memoizedAnomalyDataChange( - lastRefresh, - explorerService, - combinedJobRecords, - swimlaneContainerWidth, - [], - timerange.earliestMs, - timerange.latestMs, - timefilter, - tableSeverity - ); - } - }), - // Load view-by swimlane data and filtered top influencers. - // mergeMap is used to have access to the already fetched data and act on it in arg #1. - // In arg #2 of mergeMap we combine the data and pass it on in the action format - // which can be consumed by explorerReducer() later on. + tap(explorerService.setChartsDataLoading), mergeMap( - ({ anomalyChartRecords, influencers, overallState, topFieldValues }) => + ({ + anomalyChartRecords, + influencers, + overallState, + topFieldValues, + annotationsData, + tableData, + }) => forkJoin({ - influencers: + anomalyChartsData: memoizedAnomalyDataChange( + lastRefresh, + combinedJobRecords, + swimlaneContainerWidth, + selectedCells !== undefined && Array.isArray(anomalyChartRecords) + ? anomalyChartRecords + : [], + timerange.earliestMs, + timerange.latestMs, + timefilter, + tableSeverity + ), + filteredTopInfluencers: (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && anomalyChartRecords !== undefined && anomalyChartRecords.length > 0 @@ -280,24 +265,26 @@ const loadExplorerDataProvider = ( swimlaneContainerWidth, influencersFilterQuery ), - }), - ( - { annotationsData, overallState, tableData }, - { influencers, viewBySwimlaneState } - ): Partial => { - return { - annotations: annotationsData, - influencers: influencers as any, - loading: false, - viewBySwimlaneDataLoading: false, - overallSwimlaneData: overallState, - viewBySwimlaneData: viewBySwimlaneState as any, - tableData, - swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState) - ? viewBySwimlaneState.cardinality - : undefined, - }; - } + }).pipe( + tap(({ anomalyChartsData }) => { + explorerService.setCharts(anomalyChartsData as ExplorerChartsData); + }), + map(({ viewBySwimlaneState, filteredTopInfluencers }) => { + return { + annotations: annotationsData, + influencers: filteredTopInfluencers as any, + loading: false, + viewBySwimlaneDataLoading: false, + anomalyChartsDataLoading: false, + overallSwimlaneData: overallState, + viewBySwimlaneData: viewBySwimlaneState as any, + tableData, + swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState) + ? viewBySwimlaneState.cardinality + : undefined, + }; + }) + ) ) ); }; @@ -319,7 +306,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) uiSettings, mlResultsService ); - const anomalyExplorerService = new AnomalyExplorerChartsService( + const anomalyExplorerChartsService = new AnomalyExplorerChartsService( timefilter, mlApiServices, mlResultsService @@ -327,7 +314,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) return loadExplorerDataProvider( mlResultsService, anomalyTimelineService, - anomalyExplorerService, + anomalyExplorerChartsService, timefilter ); }, []); diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx deleted file mode 100644 index 8fe2c32b766b4..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ /dev/null @@ -1,312 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC, useCallback, useMemo, useState, useEffect } from 'react'; -import { debounce } from 'lodash'; -import { - EuiFormRow, - EuiCheckboxGroup, - EuiInMemoryTableProps, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSpacer, - EuiButtonEmpty, - EuiButton, - EuiModalFooter, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiModalBody } from '@elastic/eui'; -import { EuiInMemoryTable } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useMlKibana } from '../contexts/kibana'; -import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public'; -import { getDefaultSwimlanePanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; -import { useDashboardService } from '../services/dashboard_service'; -import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; -import { JobId } from '../../../common/types/anomaly_detection_jobs'; -import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../../embeddables'; - -export interface DashboardItem { - id: string; - title: string; - description: string | undefined; - attributes: DashboardSavedObject; -} - -export type EuiTableProps = EuiInMemoryTableProps; - -function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) { - return { - type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, - title: getDefaultSwimlanePanelTitle(jobIds), - }; -} - -interface AddToDashboardControlProps { - jobIds: JobId[]; - viewBy: string; - onClose: (callback?: () => Promise) => void; -} - -/** - * Component for attaching anomaly swim lane embeddable to dashboards. - */ -export const AddToDashboardControl: FC = ({ - onClose, - jobIds, - viewBy, -}) => { - const { - notifications: { toasts }, - services: { - application: { navigateToUrl }, - }, - } = useMlKibana(); - - useEffect(() => { - fetchDashboards(); - - return () => { - fetchDashboards.cancel(); - }; - }, []); - - const dashboardService = useDashboardService(); - - const [isLoading, setIsLoading] = useState(false); - const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({ - [SWIMLANE_TYPE.OVERALL]: true, - [SWIMLANE_TYPE.VIEW_BY]: false, - }); - const [dashboardItems, setDashboardItems] = useState([]); - const [selectedItems, setSelectedItems] = useState([]); - - const fetchDashboards = useCallback( - debounce(async (query?: string) => { - try { - const response = await dashboardService.fetchDashboards(query); - const items: DashboardItem[] = response.savedObjects.map((savedObject) => { - return { - id: savedObject.id, - title: savedObject.attributes.title, - description: savedObject.attributes.description, - attributes: savedObject.attributes, - }; - }); - setDashboardItems(items); - } catch (e) { - toasts.danger({ - body: e, - }); - } - setIsLoading(false); - }, 500), - [] - ); - - const search: EuiTableProps['search'] = useMemo(() => { - return { - onChange: ({ queryText }) => { - setIsLoading(true); - fetchDashboards(queryText); - }, - box: { - incremental: true, - 'data-test-subj': 'mlDashboardsSearchBox', - }, - }; - }, []); - - const addSwimlaneToDashboardCallback = useCallback(async () => { - const swimlanes = Object.entries(selectedSwimlanes) - .filter(([, isSelected]) => isSelected) - .map(([swimlaneType]) => swimlaneType); - - for (const selectedDashboard of selectedItems) { - const panelsData = swimlanes.map((swimlaneType) => { - const config = getDefaultEmbeddablePanelConfig(jobIds); - if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { - return { - ...config, - embeddableConfig: { - jobIds, - swimlaneType, - viewBy, - }, - }; - } - return { - ...config, - embeddableConfig: { - jobIds, - swimlaneType, - }, - }; - }); - - try { - await dashboardService.attachPanels( - selectedDashboard.id, - selectedDashboard.attributes, - panelsData - ); - toasts.success({ - title: ( - - ), - toastLifeTimeMs: 3000, - }); - } catch (e) { - toasts.danger({ - body: e, - }); - } - } - }, [selectedSwimlanes, selectedItems]); - - const columns: EuiTableProps['columns'] = [ - { - field: 'title', - name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', { - defaultMessage: 'Title', - }), - sortable: true, - truncateText: true, - }, - { - field: 'description', - name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', { - defaultMessage: 'Description', - }), - truncateText: true, - }, - ]; - - const swimlaneTypeOptions = [ - { - id: SWIMLANE_TYPE.OVERALL, - label: i18n.translate('xpack.ml.explorer.overallLabel', { - defaultMessage: 'Overall', - }), - }, - { - id: SWIMLANE_TYPE.VIEW_BY, - label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { - defaultMessage: 'View by {viewByField}', - values: { viewByField: viewBy }, - }), - }, - ]; - - const selection: EuiTableProps['selection'] = { - onSelectionChange: setSelectedItems, - }; - - const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); - - return ( - - - - - - - - - } - > - { - const newSelection = { - ...selectedSwimlanes, - [optionId]: !selectedSwimlanes[optionId as SwimlaneType], - }; - setSelectedSwimlanes(newSelection); - }} - data-test-subj="mlAddToDashboardSwimlaneTypeSelector" - /> - - - - - - } - data-test-subj="mlDashboardSelectionContainer" - > - - - - - - - - { - onClose(async () => { - const selectedDashboardId = selectedItems[0].id; - await addSwimlaneToDashboardCallback(); - await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId)); - }); - }} - data-test-subj="mlAddAndEditDashboardButton" - > - - - - - - - - ); -}; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx new file mode 100644 index 0000000000000..9f65449169ee6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState, FC } from 'react'; +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../contexts/kibana'; +import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { AddAnomalyChartsToDashboardControl } from './dashboard_controls/add_anomaly_charts_to_dashboard_controls'; + +interface AnomalyContextMenuProps { + selectedJobs: ExplorerJob[]; + selectedCells?: AppStateSelectedCells; + bounds?: TimeRangeBounds; + interval?: number; + chartsCount: number; +} +export const AnomalyContextMenu: FC = ({ + selectedJobs, + selectedCells, + bounds, + interval, + chartsCount, +}) => { + const { + services: { + application: { capabilities }, + }, + } = useMlKibana(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); + + const canEditDashboards = capabilities.dashboard?.createNew ?? false; + const menuItems = useMemo(() => { + const items = []; + if (canEditDashboards) { + items.push( + + + + ); + } + return items; + }, [canEditDashboards]); + + const jobIds = selectedJobs.map(({ id }) => id); + + return ( + <> + {menuItems.length > 0 && ( + + + } + isOpen={isMenuOpen} + closePopover={setIsMenuOpen.bind(null, false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + )} + {isAddDashboardsActive && selectedJobs && ( + { + setIsAddDashboardActive(false); + if (callback) { + await callback(); + } + }} + selectedCells={selectedCells} + bounds={bounds} + interval={interval} + jobIds={jobIds} + /> + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 7c63d4087ce1e..37967d18dbbd9 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -24,7 +24,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; -import { AddToDashboardControl } from './add_to_dashboard_control'; +import { AddSwimlaneToDashboardControl } from './dashboard_controls/add_swimlane_to_dashboard_controls'; import { useMlKibana } from '../contexts/kibana'; import { TimeBuckets } from '../util/time_buckets'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; @@ -294,7 +294,7 @@ export const AnomalyTimeline: FC = React.memo( )} {isAddDashboardsActive && selectedJobs && ( - { setIsAddDashboardActive(false); if (callback) { diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx new file mode 100644 index 0000000000000..5c3c6edee59c5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { FC, useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFieldNumber, EuiFormRow, formatDate } from '@elastic/eui'; +import { useDashboardTable } from './use_dashboards_table'; +import { AddToDashboardControl } from './add_to_dashboard_controls'; +import { useAddToDashboardActions } from './use_add_to_dashboard_actions'; +import { AppStateSelectedCells, getSelectionTimeRange } from '../explorer_utils'; +import { TimeRange } from '../../../../../../../src/plugins/data/common/query'; +import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../services/anomaly_explorer_charts_service'; +import { JobId } from '../../../../common/types/anomaly_detection_jobs'; +import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../../embeddables'; +import { getDefaultExplorerChartsPanelTitle } from '../../../embeddables/anomaly_charts/anomaly_charts_embeddable'; +import { TimeRangeBounds } from '../../util/time_buckets'; +import { useTableSeverity } from '../../components/controls/select_severity'; +import { MAX_ANOMALY_CHARTS_ALLOWED } from '../../../embeddables/anomaly_charts/anomaly_charts_initializer'; + +function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) { + return { + type: ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + title: getDefaultExplorerChartsPanelTitle(jobIds), + }; +} + +export interface AddToDashboardControlProps { + jobIds: string[]; + selectedCells?: AppStateSelectedCells; + bounds?: TimeRangeBounds; + interval?: number; + onClose: (callback?: () => Promise) => void; +} + +/** + * Component for attaching anomaly swim lane embeddable to dashboards. + */ +export const AddAnomalyChartsToDashboardControl: FC = ({ + onClose, + jobIds, + selectedCells, + bounds, + interval, +}) => { + const [severity] = useTableSeverity(); + const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(DEFAULT_MAX_SERIES_TO_PLOT); + + const getPanelsData = useCallback(async () => { + let timeRange: TimeRange | undefined; + if (selectedCells !== undefined && interval !== undefined && bounds !== undefined) { + const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, interval, bounds); + timeRange = { + from: formatDate(earliestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'), + to: formatDate(latestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'), + mode: 'absolute', + }; + } + + const config = getDefaultEmbeddablePanelConfig(jobIds); + return [ + { + ...config, + embeddableConfig: { + jobIds, + maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT, + severityThreshold: severity.val, + ...(timeRange ?? {}), + }, + }, + ]; + }, [selectedCells, interval, bounds, jobIds, maxSeriesToPlot, severity]); + + const { selectedItems, selection, dashboardItems, isLoading, search } = useDashboardTable(); + const { addToDashboardAndEditCallback, addToDashboardCallback } = useAddToDashboardActions({ + onClose, + getPanelsData, + selectedDashboards: selectedItems, + }); + const title = ( + + ); + + const disabled = selectedItems.length < 1 && !Array.isArray(jobIds === undefined); + + const extraControls = ( + + } + > + setMaxSeriesToPlot(parseInt(e.target.value, 10))} + min={0} + max={MAX_ANOMALY_CHARTS_ALLOWED} + /> + + ); + + return ( + + {extraControls} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx new file mode 100644 index 0000000000000..79089e7e5baf9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useState } from 'react'; +import { EuiFormRow, EuiCheckboxGroup, EuiInMemoryTableProps, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { DashboardSavedObject } from '../../../../../../../src/plugins/dashboard/public'; +import { getDefaultSwimlanePanelTitle } from '../../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { SWIMLANE_TYPE, SwimlaneType } from '../explorer_constants'; +import { JobId } from '../../../../common/types/anomaly_detection_jobs'; +import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../../../embeddables'; +import { useDashboardTable } from './use_dashboards_table'; +import { AddToDashboardControl } from './add_to_dashboard_controls'; +import { useAddToDashboardActions } from './use_add_to_dashboard_actions'; + +export interface DashboardItem { + id: string; + title: string; + description: string | undefined; + attributes: DashboardSavedObject; +} + +export type EuiTableProps = EuiInMemoryTableProps; + +function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) { + return { + type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + title: getDefaultSwimlanePanelTitle(jobIds), + }; +} + +interface AddToDashboardControlProps { + jobIds: JobId[]; + viewBy: string; + onClose: (callback?: () => Promise) => void; +} + +/** + * Component for attaching anomaly swim lane embeddable to dashboards. + */ +export const AddSwimlaneToDashboardControl: FC = ({ + onClose, + jobIds, + viewBy, +}) => { + const { selectedItems, selection, dashboardItems, isLoading, search } = useDashboardTable(); + + const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({ + [SWIMLANE_TYPE.OVERALL]: true, + [SWIMLANE_TYPE.VIEW_BY]: false, + }); + + const getPanelsData = useCallback(async () => { + const swimlanes = Object.entries(selectedSwimlanes) + .filter(([, isSelected]) => isSelected) + .map(([swimlaneType]) => swimlaneType); + + return swimlanes.map((swimlaneType) => { + const config = getDefaultEmbeddablePanelConfig(jobIds); + if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + viewBy, + }, + }; + } + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + }, + }; + }); + }, [selectedSwimlanes, selectedItems]); + const { addToDashboardAndEditCallback, addToDashboardCallback } = useAddToDashboardActions({ + onClose, + getPanelsData, + selectedDashboards: selectedItems, + }); + + const swimlaneTypeOptions = [ + { + id: SWIMLANE_TYPE.OVERALL, + label: i18n.translate('xpack.ml.explorer.overallLabel', { + defaultMessage: 'Overall', + }), + }, + { + id: SWIMLANE_TYPE.VIEW_BY, + label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { + defaultMessage: 'View by {viewByField}', + values: { viewByField: viewBy }, + }), + }, + ]; + + const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); + + const extraControls = ( + <> + + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + + + + ); + + const title = ( + + ); + + const disabled = noSwimlaneSelected || selectedItems.length === 0; + return ( + + {extraControls} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx new file mode 100644 index 0000000000000..7806e531834a1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { FC } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFormRow, + EuiInMemoryTable, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTableProps, useDashboardTable } from './use_dashboards_table'; + +export const columns: EuiTableProps['columns'] = [ + { + field: 'title', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', { + defaultMessage: 'Title', + }), + sortable: true, + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', { + defaultMessage: 'Description', + }), + truncateText: true, + }, +]; + +interface AddToDashboardControlProps extends ReturnType { + onClose: (callback?: () => Promise) => void; + addToDashboardAndEditCallback: () => Promise; + addToDashboardCallback: () => Promise; + title: React.ReactNode; + disabled: boolean; + children?: React.ReactElement; +} +export const AddToDashboardControl: FC = ({ + onClose, + selection, + dashboardItems, + isLoading, + search, + addToDashboardAndEditCallback, + addToDashboardCallback, + title, + disabled, + children, +}) => { + return ( + + + {title} + + + {children} + + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx new file mode 100644 index 0000000000000..82c699865f2e4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DashboardItem } from './use_dashboards_table'; +import { SavedDashboardPanel } from '../../../../../../../src/plugins/dashboard/common/types'; +import { useMlKibana } from '../../contexts/kibana'; +import { useDashboardService } from '../../services/dashboard_service'; + +export const useAddToDashboardActions = ({ + onClose, + getPanelsData, + selectedDashboards, +}: { + onClose: (callback?: () => Promise) => void; + getPanelsData: ( + selectedDashboards: DashboardItem[] + ) => Promise>>; + selectedDashboards: DashboardItem[]; +}) => { + const { + notifications: { toasts }, + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + const dashboardService = useDashboardService(); + + const addToDashboardCallback = useCallback(async () => { + const panelsData = await getPanelsData(selectedDashboards); + for (const selectedDashboard of selectedDashboards) { + try { + await dashboardService.attachPanels( + selectedDashboard.id, + selectedDashboard.attributes, + panelsData + ); + toasts.success({ + title: ( + + ), + toastLifeTimeMs: 3000, + }); + } catch (e) { + toasts.danger({ + body: e, + }); + } + } + }, [selectedDashboards, getPanelsData]); + + const addToDashboardAndEditCallback = useCallback(async () => { + onClose(async () => { + await addToDashboardCallback(); + const selectedDashboardId = selectedDashboards[0].id; + await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId)); + }); + }, [addToDashboardCallback, selectedDashboards, navigateToUrl]); + + return { addToDashboardCallback, addToDashboardAndEditCallback }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx new file mode 100644 index 0000000000000..8721de497eedc --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiInMemoryTableProps } from '@elastic/eui'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { debounce } from 'lodash'; +import type { DashboardSavedObject } from '../../../../../../../src/plugins/dashboard/public'; +import { useDashboardService } from '../../services/dashboard_service'; +import { useMlKibana } from '../../contexts/kibana'; + +export interface DashboardItem { + id: string; + title: string; + description: string | undefined; + attributes: DashboardSavedObject; +} + +export type EuiTableProps = EuiInMemoryTableProps; + +export const useDashboardTable = () => { + const { + notifications: { toasts }, + } = useMlKibana(); + + const dashboardService = useDashboardService(); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + fetchDashboards(); + + return () => { + fetchDashboards.cancel(); + }; + }, []); + + const search: EuiTableProps['search'] = useMemo(() => { + return { + onChange: ({ queryText }) => { + setIsLoading(true); + fetchDashboards(queryText); + }, + box: { + incremental: true, + 'data-test-subj': 'mlDashboardsSearchBox', + }, + }; + }, []); + + const [dashboardItems, setDashboardItems] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + + const fetchDashboards = useCallback( + debounce(async (query?: string) => { + try { + const response = await dashboardService.fetchDashboards(query); + const items: DashboardItem[] = response.savedObjects.map((savedObject) => { + return { + id: savedObject.id, + title: savedObject.attributes.title, + description: savedObject.attributes.description, + attributes: savedObject.attributes, + }; + }); + setDashboardItems(items); + } catch (e) { + toasts.danger({ + body: e, + }); + } + setIsLoading(false); + }, 500), + [] + ); + const selection: EuiTableProps['selection'] = { + onSelectionChange: setSelectedItems, + }; + return { dashboardItems, selectedItems, selection, search, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 6979277c43077..45665b2026db5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -72,6 +72,7 @@ import { getToastNotifications } from '../util/dependency_cache'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; +import { AnomalyContextMenu } from './anomaly_context_menu'; const ExplorerPage = ({ children, @@ -431,14 +432,32 @@ export class ExplorerUI extends React.Component { )} {loading === false && ( - -

- + + +

+ +

+
+
+ + + -

-
+
+
{ + explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA }); + }, clearInfluencerFilterSettings: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS }); }, @@ -137,6 +140,9 @@ export const explorerService = { setFilterData: (payload: Partial>) => { explorerAction$.next(setFilterDataActionCreator(payload)); }, + setChartsDataLoading: () => { + explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING }); + }, setSwimlaneContainerWidth: (payload: number) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 9e24a4349584e..b410449218d02 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -12,6 +12,7 @@ import { TimeRangeBounds } from '../util/time_buckets'; import { RecordForInfluencer } from '../services/results_service/results_service'; import { InfluencersFilterQuery } from '../../../common/types/es_client'; import { MlResultsService } from '../services/results_service'; +import { EntityField } from '../../../common/util/anomaly_utils'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -60,7 +61,7 @@ export declare const getSelectionJobIds: ( export declare const getSelectionInfluencers: ( selectedCells: AppStateSelectedCells | undefined, fieldName: string -) => string[]; +) => EntityField[]; interface SelectionTimeRange { earliestMs: number; @@ -149,6 +150,7 @@ export declare const loadDataForCharts: ( ) => Promise; export declare const loadFilteredTopInfluencers: ( + mlResultsService: MlResultsService, jobIds: string[], earliestMs: number, latestMs: number, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index ea101d104f783..69bdac060a2dc 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -536,65 +536,6 @@ export async function loadAnomaliesTableData( }); } -// track the request to be able to ignore out of date requests -// and avoid race conditions ending up with the wrong charts. -let requestCount = 0; -export async function loadDataForCharts( - mlResultsService, - jobIds, - earliestMs, - latestMs, - influencers = [], - selectedCells, - influencersFilterQuery, - // choose whether or not to keep track of the request that could be out of date - // in Anomaly Explorer this is being used to ignore any request that are out of date - // but in embeddables, we might have multiple requests coming from multiple different panels - takeLatestOnly = true -) { - return new Promise((resolve) => { - // Just skip doing the request when this function - // is called without the minimum required data. - if ( - selectedCells === undefined && - influencers.length === 0 && - influencersFilterQuery === undefined - ) { - resolve([]); - } - - const newRequestCount = ++requestCount; - requestCount = newRequestCount; - - // Load the top anomalies (by record_score) which will be displayed in the charts. - mlResultsService - .getRecordsForInfluencer( - jobIds, - influencers, - 0, - earliestMs, - latestMs, - 500, - influencersFilterQuery - ) - .then((resp) => { - // Ignore this response if it's returned by an out of date promise - if (takeLatestOnly && newRequestCount < requestCount) { - resolve([]); - } - - if ( - (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || - influencersFilterQuery !== undefined - ) { - resolve(resp.records); - } - - resolve([]); - }); - }); -} - export async function loadTopInfluencers( mlResultsService, selectedJobIds, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index f66cd94314608..15e0caa29af39 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -20,7 +20,7 @@ import { import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; import { jobSelectionChange } from './job_selection_change'; -import { ExplorerState } from './state'; +import { ExplorerState, getExplorerDefaultState } from './state'; import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; import { getTimeBoundsFromSelection } from '../../hooks/use_selected_cells'; @@ -31,6 +31,10 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo let nextState: ExplorerState; switch (type) { + case EXPLORER_ACTION.CLEAR_EXPLORER_DATA: + nextState = getExplorerDefaultState(); + break; + case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS: nextState = clearInfluencerFilterSettings(state); break; @@ -49,6 +53,14 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = jobSelectionChange(state, payload); break; + case EXPLORER_ACTION.SET_CHARTS_DATA_LOADING: + nextState = { + ...state, + anomalyChartsDataLoading: true, + chartsData: getDefaultChartsData(), + }; + break; + case EXPLORER_ACTION.SET_CHARTS: nextState = { ...state, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index bb90fedfc2315..e9527b7c232e5 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -28,6 +28,7 @@ import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export interface ExplorerState { annotations: AnnotationsTable; + anomalyChartsDataLoading: boolean; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; filterActive: boolean; @@ -69,6 +70,7 @@ export function getExplorerDefaultState(): ExplorerState { annotationsData: [], aggregations: {}, }, + anomalyChartsDataLoading: true, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, filterActive: false, diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index b651b311f13aa..3e5cf252230a2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -159,6 +159,14 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [JSON.stringify(jobIds)]); + useEffect(() => { + return () => { + // upon component unmounting + // clear any data to prevent next page from rendering old charts + explorerService.clearExplorerData(); + }; + }, []); + /** * TODO get rid of the intermediate state in explorerService. * URL state should be the only source of truth for related props. diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts index 21f07ed9e5a3c..28140038d249b 100644 --- a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts @@ -10,4 +10,5 @@ export const createAnomalyExplorerChartsServiceMock = () => ({ getAnomalyData: jest.fn(), setTimeRange: jest.fn(), getTimeBounds: jest.fn(), + loadDataForCharts$: jest.fn(), }); diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts index 36e18b49cfa84..ac61e11b1128e 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts @@ -13,7 +13,6 @@ import { of } from 'rxjs'; import { cloneDeep } from 'lodash'; import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; -import type { ExplorerService } from '../explorer/explorer_dashboard_service'; import type { MlApiServices } from './ml_api_service'; import type { MlResultsService } from './results_service'; import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; @@ -89,9 +88,6 @@ describe('AnomalyExplorerChartsService', () => { (mlApiServicesMock as unknown) as MlApiServices, (mlResultsServiceMock as unknown) as MlResultsService ); - const explorerService = { - setCharts: jest.fn(), - }; const timeRange = { earliestMs: 1486656000000, @@ -104,13 +100,8 @@ describe('AnomalyExplorerChartsService', () => { ); }); - afterEach(() => { - explorerService.setCharts.mockClear(); - }); - test('should return anomaly data without explorer service', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecords, @@ -123,27 +114,8 @@ describe('AnomalyExplorerChartsService', () => { assertAnomalyDataResult(anomalyData); }); - test('should set anomaly data with explorer service side effects', async () => { - await anomalyExplorerService.getAnomalyData( - (explorerService as unknown) as ExplorerService, - (combinedJobRecords as unknown) as Record, - 1000, - mockAnomalyChartRecords, - timeRange.earliestMs, - timeRange.latestMs, - timefilterMock, - 0, - 12 - ); - - expect(explorerService.setCharts.mock.calls.length).toBe(2); - assertAnomalyDataResult(explorerService.setCharts.mock.calls[0][0]); - assertAnomalyDataResult(explorerService.setCharts.mock.calls[1][0]); - }); - test('call anomalyChangeListener with empty series config', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, // @ts-ignore (combinedJobRecords as unknown) as Record, 1000, @@ -165,7 +137,6 @@ describe('AnomalyExplorerChartsService', () => { mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecordsClone, diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index 72de5d003d4b8..7aff2ff7e0026 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -7,6 +7,8 @@ import { each, find, get, map, reduce, sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { Observable, of } from 'rxjs'; +import { map as mapObservable } from 'rxjs/operators'; import { RecordForInfluencer } from './results_service/results_service'; import { isMappableJob, @@ -29,7 +31,6 @@ import { CHART_TYPE, ChartType } from '../explorer/explorer_constants'; import type { ChartRecord } from '../explorer/explorer_utils'; import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx'; import { isPopulatedObject } from '../../../common/util/object_utils'; -import type { ExplorerService } from '../explorer/explorer_dashboard_service'; import { AnomalyRecordDoc } from '../../../common/types/anomalies'; import { ExplorerChartsData, @@ -37,6 +38,8 @@ import { } from '../explorer/explorer_charts/explorer_charts_container_service'; import { TimeRangeBounds } from '../util/time_buckets'; import { isDefined } from '../../../common/types/guards'; +import { AppStateSelectedCells } from '../explorer/explorer_utils'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; const CHART_MAX_POINTS = 500; const ANOMALIES_MAX_RESULTS = 500; const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. @@ -370,15 +373,53 @@ export class AnomalyExplorerChartsService { // Getting only necessary job config and datafeed config without the stats jobIds.map((jobId) => this.mlApiServices.jobs.jobForCloning(jobId)) ); - const combinedJobs = combinedResults + return combinedResults .filter(isDefined) .filter((r) => r.job !== undefined && r.datafeed !== undefined) .map(({ job, datafeed }) => ({ ...job, datafeed_config: datafeed } as CombinedJob)); - return combinedJobs; + } + + public loadDataForCharts$( + jobIds: string[], + earliestMs: number, + latestMs: number, + influencers: EntityField[] = [], + selectedCells: AppStateSelectedCells | undefined, + influencersFilterQuery: InfluencersFilterQuery + ): Observable { + if ( + selectedCells === undefined && + influencers.length === 0 && + influencersFilterQuery === undefined + ) { + of([]); + } + + return this.mlResultsService + .getRecordsForInfluencer$( + jobIds, + influencers, + 0, + earliestMs, + latestMs, + 500, + influencersFilterQuery + ) + .pipe( + mapObservable((resp): RecordForInfluencer[] => { + if ( + (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || + influencersFilterQuery !== undefined + ) { + return resp.records; + } + + return [] as RecordForInfluencer[]; + }) + ); } public async getAnomalyData( - explorerService: ExplorerService | undefined, combinedJobRecords: Record, chartsContainerWidth: number, anomalyRecords: ChartRecord[] | undefined, @@ -486,9 +527,6 @@ export class AnomalyExplorerChartsService { data.errorMessages = errorMessages; } - if (explorerService) { - explorerService.setCharts({ ...data }); - } if (seriesConfigs.length === 0) { return data; } @@ -848,9 +886,6 @@ export class AnomalyExplorerChartsService { // push map data in if it's available data.seriesToPlot.push(...mapData); } - if (explorerService) { - explorerService.setCharts({ ...data }); - } return Promise.resolve(data); }) .catch((error) => { @@ -860,7 +895,7 @@ export class AnomalyExplorerChartsService { } public processRecordsForDisplay( - jobRecords: Record, + combinedJobRecords: Record, anomalyRecords: RecordForInfluencer[] ): { records: ChartRecord[]; errors: Record> | undefined } { // Aggregate the anomaly data by detector, and entity (by/over/partition). @@ -875,7 +910,7 @@ export class AnomalyExplorerChartsService { // Check if we can plot a chart for this record, depending on whether the source data // is chartable, and if model plot is enabled for the job. - const job = jobRecords[record.job_id]; + const job = combinedJobRecords[record.job_id]; // if we already know this job has datafeed aggregations we cannot support // no need to do more checks diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index e07d49ca23d3b..caa0e20c3230d 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -22,9 +22,11 @@ import { MlApiServices } from '../ml_api_service'; import { CriteriaField } from './index'; import { findAggField } from '../../../../common/util/validation_utils'; import { getDatafeedAggregations } from '../../../../common/util/datafeed_utils'; -import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; +import { aggregationTypeTransform, EntityField } from '../../../../common/util/anomaly_utils'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { isPopulatedObject } from '../../../../common/util/object_utils'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import { RecordForInfluencer } from './results_service'; interface ResultResponse { success: boolean; @@ -633,5 +635,135 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { latestMs ); }, + + // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), + // for the specified job(s), time range, and record score threshold. + // influencers parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, + // so this returns record level results which have at least one of the influencers. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForInfluencer$( + jobIds: string[], + influencers: EntityField[], + threshold: number, + earliestMs: number, + latestMs: number, + maxResults: number, + influencersFilterQuery: InfluencersFilterQuery + ): Observable<{ records: RecordForInfluencer[]; success: boolean }> { + const obj = { success: true, records: [] as RecordForInfluencer[] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } + + // Add a nested query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ + bool: { + should: influencers.map((influencer) => { + return { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencer.fieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencer.fieldValue, + }, + }, + ], + }, + }, + }, + }; + }), + minimum_should_match: 1, + }, + }); + } + + return mlApiServices.results + .anomalySearch$( + { + size: maxResults !== undefined ? maxResults : 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }, + jobIds + ) + .pipe( + map((resp) => { + if (resp.hits.total.value > 0) { + each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + return obj; + }) + ); + }, }; } diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index d26e650d145cb..6161eeb4e7940 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -55,7 +55,6 @@ export function resultsServiceProvider( influencersFilterQuery: InfluencersFilterQuery ): Promise; getRecordInfluencers(): Promise; - getRecordsForInfluencer(): Promise; getRecordsForDetector(): Promise; getRecords(): Promise; getEventRateData( diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index b041267f46c04..c258d07cab484 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -779,139 +779,6 @@ export function resultsServiceProvider(mlApiServices) { }); }, - // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), - // for the specified job(s), time range, and record score threshold. - // influencers parameter must be an array, with each object in the array having 'fieldName' - // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, - // so this returns record level results which have at least one of the influencers. - // Pass an empty array or ['*'] to search over all job IDs. - getRecordsForInfluencer( - jobIds, - influencers, - threshold, - earliestMs, - latestMs, - maxResults, - influencersFilterQuery - ) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a nested query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - match: { - 'influencers.influencer_field_name': influencer.fieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencer.fieldValue, - }, - }, - ], - }, - }, - }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - mlApiServices.results - .anomalySearch( - { - size: maxResults !== undefined ? maxResults : 100, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }, - jobIds - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - }, - // Queries Elasticsearch to obtain the record level results for the specified job and detector, // time range, record score threshold, and whether to only return results containing influencers. // An additional, optional influencer field name and value may also be provided. @@ -1039,14 +906,6 @@ export function resultsServiceProvider(mlApiServices) { }); }, - // Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, - // and record score threshold. - // Pass an empty array or ['*'] to search over all job IDs. - // Returned response contains a records property, which is an array of the matching results. - getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { - return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); - }, - // Queries Elasticsearch to obtain event rate data i.e. the count // of documents over time. // index can be a String, or String[], of index names to search. diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx index f32446fd6d9ab..a36d063737704 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx @@ -23,7 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyChartsEmbeddableInput } from '..'; import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../application/services/anomaly_explorer_charts_service'; -const MAX_SERIES_ALLOWED = 48; +export const MAX_ANOMALY_CHARTS_ALLOWED = 48; export interface AnomalyChartsInitializerProps { defaultTitle: string; initialInput?: Partial>; @@ -98,7 +98,7 @@ export const AnomalyChartsInitializer: FC = ({ value={maxSeriesToPlot} onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))} min={0} - max={MAX_SERIES_ALLOWED} + max={MAX_ANOMALY_CHARTS_ALLOWED} /> diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts index efac51edda69f..7045b2eac378a 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts @@ -29,41 +29,6 @@ jest.mock('../../application/explorer/explorer_utils', () => ({ }), getSelectionJobIds: jest.fn(() => ['test-job']), getSelectionTimeRange: jest.fn(() => ({ earliestMs: 1521309543000, latestMs: 1616003942999 })), - loadDataForCharts: jest.fn().mockImplementation(() => - Promise.resolve([ - { - job_id: 'cw_multi_1', - result_type: 'record', - probability: 6.057139142746412e-13, - multi_bucket_impact: -5, - record_score: 89.71961, - initial_record_score: 98.36826274948001, - bucket_span: 900, - detector_index: 0, - is_interim: false, - timestamp: 1572892200000, - partition_field_name: 'instance', - partition_field_value: 'i-d17dcd4c', - function: 'mean', - function_description: 'mean', - typical: [1.6177685422858146], - actual: [7.235333333333333], - field_name: 'CPUUtilization', - influencers: [ - { - influencer_field_name: 'region', - influencer_field_values: ['sa-east-1'], - }, - { - influencer_field_name: 'instance', - influencer_field_values: ['i-d17dcd4c'], - }, - ], - instance: ['i-d17dcd4c'], - region: ['sa-east-1'], - }, - ]) - ), })); describe('useAnomalyChartsInputResolver', () => { @@ -115,6 +80,42 @@ describe('useAnomalyChartsInputResolver', () => { }) ); + anomalyExplorerChartsServiceMock.loadDataForCharts$.mockImplementation(() => + Promise.resolve([ + { + job_id: 'cw_multi_1', + result_type: 'record', + probability: 6.057139142746412e-13, + multi_bucket_impact: -5, + record_score: 89.71961, + initial_record_score: 98.36826274948001, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1572892200000, + partition_field_name: 'instance', + partition_field_value: 'i-d17dcd4c', + function: 'mean', + function_description: 'mean', + typical: [1.6177685422858146], + actual: [7.235333333333333], + field_name: 'CPUUtilization', + influencers: [ + { + influencer_field_name: 'region', + influencer_field_values: ['sa-east-1'], + }, + { + influencer_field_name: 'instance', + influencer_field_values: ['i-d17dcd4c'], + }, + ], + instance: ['i-d17dcd4c'], + region: ['sa-east-1'], + }, + ]) + ); + const coreStartMock = createCoreStartMock(); const mlStartMock = createMlStartDepsMock(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts index b114ca89a3288..703851f3fe9b6 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -18,7 +18,6 @@ import { getSelectionInfluencers, getSelectionJobIds, getSelectionTimeRange, - loadDataForCharts, } from '../../application/explorer/explorer_utils'; import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { parseInterval } from '../../../common/util/parse_interval'; @@ -46,7 +45,7 @@ export function useAnomalyChartsInputResolver( const [ { uiSettings }, { data: dataServices }, - { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + { anomalyDetectorService, anomalyExplorerService }, ] = services; const { timefilter } = dataServices.query.timefilter; @@ -125,15 +124,13 @@ export function useAnomalyChartsInputResolver( const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds); return forkJoin({ combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds), - anomalyChartRecords: loadDataForCharts( - mlResultsService, + anomalyChartRecords: anomalyExplorerService.loadDataForCharts$( jobIds, timeRange.earliestMs, timeRange.latestMs, selectionInfluencers, selections, - influencersFilterQuery, - false + influencersFilterQuery ), }).pipe( switchMap(({ combinedJobs, anomalyChartRecords }) => { @@ -147,7 +144,6 @@ export function useAnomalyChartsInputResolver( return forkJoin({ chartsData: from( anomalyExplorerService.getAnomalyData( - undefined, combinedJobRecords, embeddableContainerWidth, anomalyChartRecords, diff --git a/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts new file mode 100644 index 0000000000000..e5c6a2345e167 --- /dev/null +++ b/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; +import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public'; + +export const createMlUrlGeneratorMock = () => + ({ + id: ML_APP_URL_GENERATOR, + isDeprecated: false, + createUrl: jest.fn(), + migrate: jest.fn(), + } as jest.Mocked>); diff --git a/x-pack/plugins/ml/public/mocks.ts b/x-pack/plugins/ml/public/mocks.ts new file mode 100644 index 0000000000000..6b55cb3b6b650 --- /dev/null +++ b/x-pack/plugins/ml/public/mocks.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createMlUrlGeneratorMock } from './ml_url_generator/__mocks__/ml_url_generator'; +import { MlPluginSetup, MlPluginStart } from './plugin'; +const createSetupContract = (): jest.Mocked => { + return { + urlGenerator: createMlUrlGeneratorMock(), + }; +}; + +const createStartContract = (): jest.Mocked => { + return { + urlGenerator: createMlUrlGeneratorMock(), + }; +}; + +export const mlPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/ml/server/mocks.ts b/x-pack/plugins/ml/server/mocks.ts new file mode 100644 index 0000000000000..e50f78a0fd99d --- /dev/null +++ b/x-pack/plugins/ml/server/mocks.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createJobServiceProviderMock } from './shared_services/providers/__mocks__/jobs_service'; +import { createAnomalyDetectorsProviderMock } from './shared_services/providers/__mocks__/anomaly_detectors'; +import { createMockMlSystemProvider } from './shared_services/providers/__mocks__/system'; +import { createModulesProviderMock } from './shared_services/providers/__mocks__/modules'; +import { createResultsServiceProviderMock } from './shared_services/providers/__mocks__/results_service'; +import { createAlertingServiceProviderMock } from './shared_services/providers/__mocks__/alerting_service'; +import { MlPluginSetup } from './plugin'; + +const createSetupContract = () => + (({ + jobServiceProvider: createJobServiceProviderMock(), + anomalyDetectorsProvider: createAnomalyDetectorsProviderMock(), + mlSystemProvider: createMockMlSystemProvider(), + modulesProvider: createModulesProviderMock(), + resultsServiceProvider: createResultsServiceProviderMock(), + alertingServiceProvider: createAlertingServiceProviderMock(), + } as unknown) as jest.Mocked); + +const createStartContract = () => jest.fn(); + +export const mlPluginServerMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 0287c2af11a7e..c6cf608fe1e0b 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -80,7 +80,7 @@ class FieldsService { if (firstKey !== undefined) { const field = fc[firstKey]; // add to the list of fields if the field type can be used by ML - if (supportedTypes.includes(field.type) === true) { + if (supportedTypes.includes(field.type) === true && field.metadata_field !== true) { fields.push({ id: k, name: k, diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts new file mode 100644 index 0000000000000..957321e61b83a --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const createAlertingServiceProviderMock = () => + jest.fn(() => ({ + preview: jest.fn(), + execute: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts new file mode 100644 index 0000000000000..12b12e4ba06df --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyDetectorsProviderMock = () => + jest.fn(() => ({ + jobs: jest.fn(), + jobStats: jest.fn(), + datafeeds: jest.fn(), + datafeedStats: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts new file mode 100644 index 0000000000000..e39373d66eff8 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createJobServiceProviderMock = () => + jest.fn(() => ({ + jobsSummary: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts new file mode 100644 index 0000000000000..b33e11dae5879 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createModulesProviderMock = () => + jest.fn(() => ({ + recognize: jest.fn(), + getModule: jest.fn(), + listModules: jest.fn(), + setup: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts new file mode 100644 index 0000000000000..7fd60d0b3428d --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createResultsServiceProviderMock = () => + jest.fn(() => ({ + getAnomaliesTableData: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts new file mode 100644 index 0000000000000..c002ddc4ced52 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createMockMlSystemProvider = () => + jest.fn(() => ({ + mlCapabilities: jest.fn(), + mlInfo: jest.fn(), + mlAnomalySearch: jest.fn(), + })); diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 5c47d0376581a..74efc1f4985a3 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "observability"], - "optionalPlugins": ["licensing", "home", "usageCollection","lens"], + "optionalPlugins": ["licensing", "home", "usageCollection","lens", "ruleRegistry"], "requiredPlugins": ["data"], "ui": true, "server": true, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts index 3fcf98f712bef..7af3252584819 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts @@ -8,7 +8,6 @@ import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants'; import { buildPhraseFilter } from '../utils'; -import { OperationType } from '../../../../../../../lens/public'; export function getServiceLatencyLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { return { @@ -20,11 +19,11 @@ export function getServiceLatencyLensConfig({ seriesId, indexPattern }: ConfigPr sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'transaction.duration.us', label: 'Latency', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: [ 'user_agent.name', 'user_agent.os.name', @@ -37,7 +36,7 @@ export function getServiceLatencyLensConfig({ seriesId, indexPattern }: ConfigPr 'client.geo.country_name', 'user_agent.device.name', ], - filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)], + filters: buildPhraseFilter('transaction.type', 'request', indexPattern), labels: { ...FieldLabels }, reportDefinitions: [ { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_throughput_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_throughput_config.ts index c0f3d6dc9b010..7b1d472ac8bbf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_throughput_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_throughput_config.ts @@ -8,7 +8,6 @@ import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants/constants'; import { buildPhraseFilter } from '../utils'; -import { OperationType } from '../../../../../../../lens/public'; export function getServiceThroughputLensConfig({ seriesId, @@ -16,18 +15,18 @@ export function getServiceThroughputLensConfig({ }: ConfigProps): DataSeries { return { id: seriesId, - reportType: 'service-latency', + reportType: 'service-throughput', defaultSeriesType: 'line', seriesTypes: ['line', 'bar'], xAxisColumn: { sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'transaction.duration.us', label: 'Throughput', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: [ 'user_agent.name', 'user_agent.os.name', @@ -40,7 +39,7 @@ export function getServiceThroughputLensConfig({ 'client.geo.country_name', 'user_agent.device.name', ], - filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)], + filters: buildPhraseFilter('transaction.type', 'request', indexPattern), labels: { ...FieldLabels }, reportDefinitions: [ { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index ed849c1eb47b3..14cd24c42e6a2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -8,6 +8,8 @@ import { AppDataType, ReportViewTypeId } from '../../types'; import { CLS_FIELD, FCP_FIELD, FID_FIELD, LCP_FIELD, TBT_FIELD } from './elasticsearch_fieldnames'; +export const DEFAULT_TIME = { from: 'now-1h', to: 'now' }; + export const FieldLabels: Record = { 'user_agent.name': 'Browser family', 'user_agent.version': 'Browser version', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts index 5b99c19dbabb7..67d72a656744c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -6,7 +6,7 @@ */ export enum URL_KEYS { - METRIC_TYPE = 'mt', + OPERATION_TYPE = 'op', REPORT_TYPE = 'rt', SERIES_TYPE = 'st', BREAK_DOWN = 'bd', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 139f3ab0d82ed..0de78c45041d4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -42,14 +42,18 @@ describe('Lens Attribute', () => { it('should return expected field type', function () { expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type'))).toEqual( JSON.stringify({ - count: 0, - name: 'transaction.type', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, + fieldMeta: { + count: 0, + name: 'transaction.type', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: 'transaction.type', + columnType: null, }) ); }); @@ -57,14 +61,18 @@ describe('Lens Attribute', () => { it('should return expected field type for custom field with default value', function () { expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual( JSON.stringify({ - count: 0, - name: 'transaction.duration.us', - type: 'number', - esTypes: ['long'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, + fieldMeta: { + count: 0, + name: 'transaction.duration.us', + type: 'number', + esTypes: ['long'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: 'transaction.duration.us', + columnType: null, }) ); }); @@ -76,20 +84,45 @@ describe('Lens Attribute', () => { expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual( JSON.stringify({ - count: 0, - name: LCP_FIELD, - type: 'number', - esTypes: ['scaled_float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, + fieldMeta: { + count: 0, + name: LCP_FIELD, + type: 'number', + esTypes: ['scaled_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: LCP_FIELD, }) ); }); - it('should return expected number column', function () { - expect(lnsAttr.getNumberColumn('transaction.duration.us')).toEqual({ + it('should return expected number range column', function () { + expect(lnsAttr.getNumberRangeColumn('transaction.duration.us')).toEqual({ + dataType: 'number', + isBucketed: true, + label: 'Page load time (Seconds)', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }); + }); + + it('should return expected number operation column', function () { + expect(lnsAttr.getNumberRangeColumn('transaction.duration.us')).toEqual({ dataType: 'number', isBucketed: true, label: 'Page load time (Seconds)', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 589a93d160068..12a5b19fb02fc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -5,10 +5,14 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash'; import { CountIndexPatternColumn, DateHistogramIndexPatternColumn, - LastValueIndexPatternColumn, + AvgIndexPatternColumn, + MedianIndexPatternColumn, + PercentileIndexPatternColumn, OperationType, PersistedIndexPatternLayer, RangeIndexPatternColumn, @@ -17,6 +21,8 @@ import { XYState, XYCurveType, DataType, + OperationMetadata, + FieldBasedIndexPatternColumn, } from '../../../../../../lens/public'; import { buildPhraseFilter, @@ -30,6 +36,15 @@ function getLayerReferenceName(layerId: string) { return `indexpattern-datasource-layer-${layerId}`; } +function buildNumberColumn(sourceField: string) { + return { + sourceField, + dataType: 'number' as DataType, + isBucketed: false, + scale: 'ratio' as OperationMetadata['scale'], + }; +} + export class LensAttributes { indexPattern: IndexPattern; layers: Record; @@ -44,7 +59,7 @@ export class LensAttributes { reportViewConfig: DataSeries, seriesType?: SeriesType, filters?: UrlFilter[], - metricType?: OperationType, + operationType?: OperationType, reportDefinitions?: Record ) { this.indexPattern = indexPattern; @@ -52,8 +67,8 @@ export class LensAttributes { this.filters = filters ?? []; this.reportDefinitions = reportDefinitions ?? {}; - if (typeof reportViewConfig.yAxisColumn.operationType !== undefined && metricType) { - reportViewConfig.yAxisColumn.operationType = metricType; + if (typeof reportViewConfig.yAxisColumn.operationType !== undefined && operationType) { + reportViewConfig.yAxisColumn.operationType = operationType as FieldBasedIndexPatternColumn['operationType']; } this.seriesType = seriesType ?? reportViewConfig.defaultSeriesType; this.reportViewConfig = reportViewConfig; @@ -93,7 +108,7 @@ export class LensAttributes { this.visualization.layers[0].splitAccessor = undefined; } - getNumberColumn(sourceField: string): RangeIndexPatternColumn { + getNumberRangeColumn(sourceField: string): RangeIndexPatternColumn { return { sourceField, label: this.reportViewConfig.labels[sourceField], @@ -109,6 +124,38 @@ export class LensAttributes { }; } + getNumberOperationColumn( + sourceField: string, + operationType: 'average' | 'median' + ): AvgIndexPatternColumn | MedianIndexPatternColumn { + return { + ...buildNumberColumn(sourceField), + label: i18n.translate('xpack.observability.expView.columns.operation.label', { + defaultMessage: '{operationType} of {sourceField}', + values: { + sourceField: this.reportViewConfig.labels[sourceField], + operationType: capitalize(operationType), + }, + }), + operationType, + }; + } + + getPercentileNumberColumn( + sourceField: string, + percentileValue: string + ): PercentileIndexPatternColumn { + return { + ...buildNumberColumn(sourceField), + label: i18n.translate('xpack.observability.expView.columns.label', { + defaultMessage: '{percentileValue} percentile of {sourceField}', + values: { sourceField, percentileValue }, + }), + operationType: 'percentile', + params: { percentile: Number(percentileValue.split('th')[0]) }, + }; + } + getDateHistogramColumn(sourceField: string): DateHistogramIndexPatternColumn { return { sourceField, @@ -121,56 +168,89 @@ export class LensAttributes { }; } - getXAxis(): - | LastValueIndexPatternColumn - | DateHistogramIndexPatternColumn - | RangeIndexPatternColumn { + getXAxis() { const { xAxisColumn } = this.reportViewConfig; - const { type: fieldType, name: fieldName } = this.getFieldMeta(xAxisColumn.sourceField)!; + return this.getColumnBasedOnType(xAxisColumn.sourceField!); + } + + getColumnBasedOnType(sourceField: string, operationType?: OperationType) { + const { fieldMeta, columnType, fieldName } = this.getFieldMeta(sourceField); + const { type: fieldType } = fieldMeta ?? {}; + + if (fieldName === 'Records') { + return this.getRecordsColumn(); + } if (fieldType === 'date') { return this.getDateHistogramColumn(fieldName); } if (fieldType === 'number') { - return this.getNumberColumn(fieldName); + if (columnType === 'operation' || operationType) { + if (operationType === 'median' || operationType === 'average') { + return this.getNumberOperationColumn(fieldName, operationType); + } + if (operationType?.includes('th')) { + return this.getPercentileNumberColumn(sourceField, operationType); + } + } + return this.getNumberRangeColumn(fieldName); } // FIXME review my approach again return this.getDateHistogramColumn(fieldName); } - getFieldMeta(sourceField?: string) { - let xAxisField = sourceField; + getCustomFieldName(sourceField: string) { + let fieldName = sourceField; + let columnType = null; - if (xAxisField) { - const rdf = this.reportViewConfig.reportDefinitions ?? []; + const rdf = this.reportViewConfig.reportDefinitions ?? []; - const customField = rdf.find(({ field }) => field === xAxisField); + const customField = rdf.find(({ field }) => field === fieldName); - if (customField) { - if (this.reportDefinitions[xAxisField]) { - xAxisField = this.reportDefinitions[xAxisField]; - } else if (customField.defaultValue) { - xAxisField = customField.defaultValue; - } else if (customField.options?.[0].field) { - xAxisField = customField.options?.[0].field; - } + if (customField) { + if (this.reportDefinitions[fieldName]) { + fieldName = this.reportDefinitions[fieldName]; + if (customField?.options) + columnType = customField?.options?.find(({ field }) => field === fieldName)?.columnType; + } else if (customField.defaultValue) { + fieldName = customField.defaultValue; + } else if (customField.options?.[0].field) { + fieldName = customField.options?.[0].field; + columnType = customField.options?.[0].columnType; } - - return this.indexPattern.getFieldByName(xAxisField); } + + return { fieldName, columnType }; + } + + getFieldMeta(sourceField: string) { + const { fieldName, columnType } = this.getCustomFieldName(sourceField); + + const fieldMeta = this.indexPattern.getFieldByName(fieldName); + + return { fieldMeta, fieldName, columnType }; } getMainYAxis() { + const { sourceField, operationType, label } = this.reportViewConfig.yAxisColumn; + + if (sourceField === 'Records' || !sourceField) { + return this.getRecordsColumn(label); + } + + return this.getColumnBasedOnType(sourceField!, operationType); + } + + getRecordsColumn(label?: string): CountIndexPatternColumn { return { dataType: 'number', isBucketed: false, - label: 'Count of records', + label: label || 'Count of records', operationType: 'count', scale: 'ratio', sourceField: 'Records', - ...this.reportViewConfig.yAxisColumn, } as CountIndexPatternColumn; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/logs/logs_frequency_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/logs/logs_frequency_config.ts index 8a27d7ddd428b..9f8a336b59d34 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/logs/logs_frequency_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/logs/logs_frequency_config.ts @@ -24,7 +24,7 @@ export function getLogsFrequencyLensConfig({ seriesId }: Props): DataSeries { yAxisColumn: { operationType: 'count', }, - hasMetricType: false, + hasOperationType: false, defaultFilters: [], breakdowns: ['agent.hostname'], filters: [], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts index 6214975d8f1dd..d4b807de11f4e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts @@ -7,7 +7,6 @@ import { DataSeries } from '../../types'; import { FieldLabels } from '../constants'; -import { OperationType } from '../../../../../../../lens/public'; interface Props { seriesId: string; @@ -23,11 +22,11 @@ export function getCPUUsageLensConfig({ seriesId }: Props): DataSeries { sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'system.cpu.user.pct', label: 'CPU Usage %', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: [], breakdowns: ['host.hostname'], filters: [], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts index 6f46c175f7882..38d1c425fc09a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts @@ -7,7 +7,6 @@ import { DataSeries } from '../../types'; import { FieldLabels } from '../constants'; -import { OperationType } from '../../../../../../../lens/public'; interface Props { seriesId: string; @@ -23,11 +22,11 @@ export function getMemoryUsageLensConfig({ seriesId }: Props): DataSeries { sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'system.memory.used.pct', label: 'Memory Usage %', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: [], breakdowns: ['host.hostname'], filters: [], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts index 1bc9fed9c3f80..07a521225b38d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts @@ -7,7 +7,6 @@ import { DataSeries } from '../../types'; import { FieldLabels } from '../constants'; -import { OperationType } from '../../../../../../../lens/public'; interface Props { seriesId: string; @@ -23,10 +22,10 @@ export function getNetworkActivityLensConfig({ seriesId }: Props): DataSeries { sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'system.memory.used.pct', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: [], breakdowns: ['host.hostname'], filters: [], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_trends_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_trends_config.ts index a1a3acd51f89c..cd38d912850cf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_trends_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_trends_config.ts @@ -10,14 +10,21 @@ import { FieldLabels } from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, + CLS_FIELD, + FCP_FIELD, + FID_FIELD, + LCP_FIELD, PROCESSOR_EVENT, SERVICE_ENVIRONMENT, SERVICE_NAME, + TBT_FIELD, + TRANSACTION_DURATION, TRANSACTION_TYPE, USER_AGENT_DEVICE, USER_AGENT_NAME, USER_AGENT_OS, USER_AGENT_VERSION, + TRANSACTION_TIME_TO_FIRST_BYTE, } from '../constants/elasticsearch_fieldnames'; export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { @@ -30,10 +37,10 @@ export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'count', - label: 'Page views', + sourceField: 'business.kpi', + operationType: 'median', }, - hasMetricType: false, + hasOperationType: false, defaultFilters: [ USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, @@ -45,10 +52,10 @@ export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): ], breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE], filters: [ - buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), - buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), ], - labels: { ...FieldLabels, SERVICE_NAME: 'Web Application' }, + labels: { ...FieldLabels, [SERVICE_NAME]: 'Web Application' }, reportDefinitions: [ { field: SERVICE_NAME, @@ -58,14 +65,18 @@ export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): field: SERVICE_ENVIRONMENT, }, { - field: 'Business.KPI', + field: 'business.kpi', custom: true, defaultValue: 'Records', options: [ - { - field: 'Records', - label: 'Page views', - }, + { field: 'Records', label: 'Page views' }, + { label: 'Page load time', field: TRANSACTION_DURATION, columnType: 'operation' }, + { label: 'Backend time', field: TRANSACTION_TIME_TO_FIRST_BYTE, columnType: 'operation' }, + { label: 'First contentful paint', field: FCP_FIELD, columnType: 'operation' }, + { label: 'Total blocking time', field: TBT_FIELD, columnType: 'operation' }, + { label: 'Largest contentful paint', field: LCP_FIELD, columnType: 'operation' }, + { label: 'First input delay', field: FID_FIELD, columnType: 'operation' }, + { label: 'Cumulative layout shift', field: CLS_FIELD, columnType: 'operation' }, ], }, ], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/performance_dist_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/performance_dist_config.ts index 7005dea29d60d..4b6d5dd6e741b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/performance_dist_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/performance_dist_config.ts @@ -19,6 +19,7 @@ import { SERVICE_NAME, TBT_FIELD, TRANSACTION_DURATION, + TRANSACTION_TIME_TO_FIRST_BYTE, TRANSACTION_TYPE, USER_AGENT_DEVICE, USER_AGENT_NAME, @@ -36,10 +37,10 @@ export function getPerformanceDistLensConfig({ seriesId, indexPattern }: ConfigP sourceField: 'performance.metric', }, yAxisColumn: { - operationType: 'count', + sourceField: 'Records', label: 'Pages loaded', }, - hasMetricType: false, + hasOperationType: false, defaultFilters: [ USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, @@ -64,6 +65,7 @@ export function getPerformanceDistLensConfig({ seriesId, indexPattern }: ConfigP defaultValue: TRANSACTION_DURATION, options: [ { label: 'Page load time', field: TRANSACTION_DURATION }, + { label: 'Backend time', field: TRANSACTION_TIME_TO_FIRST_BYTE }, { label: 'First contentful paint', field: FCP_FIELD }, { label: 'Total blocking time', field: TBT_FIELD }, // FIXME, review if we need these descriptions @@ -74,8 +76,8 @@ export function getPerformanceDistLensConfig({ seriesId, indexPattern }: ConfigP }, ], filters: [ - buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), - buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), ], labels: { ...FieldLabels, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts index 4f036f0b9be65..8dad1839f0bcd 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts @@ -16,6 +16,7 @@ export const syntheticsFieldFormats: FieldFormat[] = [ inputFormat: 'microseconds', outputFormat: 'asMilliseconds', outputPrecision: 0, + showSuffix: true, }, }, }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts index f0ec3f0c31bef..efbc3d14441c2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts @@ -6,8 +6,7 @@ */ import { DataSeries } from '../../types'; -import { FieldLabels } from '../constants/constants'; -import { OperationType } from '../../../../../../../lens/public'; +import { FieldLabels } from '../constants'; interface Props { seriesId: string; @@ -23,11 +22,11 @@ export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { sourceField: '@timestamp', }, yAxisColumn: { - operationType: 'average' as OperationType, + operationType: 'average', sourceField: 'monitor.duration.us', label: 'Monitor duration (ms)', }, - hasMetricType: true, + hasOperationType: true, defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'], breakdowns: [ 'observer.geo.name', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts index 40c9f5750fb4d..68a36dcdcaf85 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts @@ -25,7 +25,7 @@ export function getMonitorPingsConfig({ seriesId }: Props): DataSeries { operationType: 'count', label: 'Monitor pings', }, - hasMetricType: false, + hasOperationType: false, defaultFilters: ['observer.geo.name'], breakdowns: ['monitor.status', 'observer.geo.name', 'monitor.type'], filters: [], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index c885673134786..c6b7b5d92d5f8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -13,7 +13,7 @@ import { URL_KEYS } from './constants/url_constants'; export function convertToShortUrl(series: SeriesUrl) { const { - metric, + operationType, seriesType, reportType, breakdown, @@ -23,7 +23,7 @@ export function convertToShortUrl(series: SeriesUrl) { } = series; return { - [URL_KEYS.METRIC_TYPE]: metric, + [URL_KEYS.OPERATION_TYPE]: operationType, [URL_KEYS.REPORT_TYPE]: reportType, [URL_KEYS.SERIES_TYPE]: seriesType, [URL_KEYS.BREAK_DOWN]: breakdown, @@ -49,6 +49,9 @@ export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') { } export function buildPhraseFilter(field: string, value: any, indexPattern: IIndexPattern) { - const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field)!; - return esFilters.buildPhraseFilter(fieldMeta, value, indexPattern); + const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta) { + return [esFilters.buildPhraseFilter(fieldMeta, value, indexPattern)]; + } + return []; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 0e7bc80e8659c..6bc069aafa5b8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -6,8 +6,7 @@ */ import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; -import styled from 'styled-components'; -import { EuiLoadingSpinner, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiTitle } from '@elastic/eui'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; @@ -15,7 +14,6 @@ import { SeriesEditor } from './series_editor/series_editor'; import { useUrlStorage } from './hooks/use_url_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; -import { useIndexPatternContext } from './hooks/use_default_index_pattern'; import { TypedLensByValueInput } from '../../../../../lens/public'; export function ExploratoryView() { @@ -27,15 +25,12 @@ export function ExploratoryView() { null ); - const { indexPattern } = useIndexPatternContext(); - const LensComponent = lens?.EmbeddableComponent; const { firstSeriesId: seriesId, firstSeries: series } = useUrlStorage(); const lensAttributesT = useLensAttributes({ seriesId, - indexPattern, }); useEffect(() => { @@ -48,11 +43,6 @@ export function ExploratoryView() { {lens ? ( <> - {!indexPattern && ( - - - - )} {lensAttributes && seriesId && series?.reportType && series?.time ? ( ); } - -const SpinnerWrap = styled.div` - height: 100vh; - display: flex; - justify-content: center; - align-items: center; -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_default_index_pattern.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_default_index_pattern.tsx index 7ead7d5e3cfad..c5a4d02492662 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_default_index_pattern.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_default_index_pattern.tsx @@ -39,6 +39,7 @@ export function IndexPatternContextProvider({ } = useKibana(); const loadIndexPattern = async (dataType: AppDataType) => { + setIndexPattern(undefined); const obsvIndexP = new ObservabilityIndexPatterns(data); const indPattern = await obsvIndexP.getIndexPattern(dataType); setIndexPattern(indPattern!); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_init_exploratory_view.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_init_exploratory_view.ts index 76fd64ef86736..de4343b290118 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_init_exploratory_view.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_init_exploratory_view.ts @@ -27,15 +27,17 @@ export const useInitExploratoryView = (storage: IKbnUrlStateStorage) => { const firstSeries = allSeries[firstSeriesId]; + let dataType: DataType = firstSeries?.dataType ?? 'rum'; + + if (firstSeries?.rt) { + dataType = ReportToDataTypeMap[firstSeries?.rt]; + } + const { data: indexPattern, error } = useFetcher(() => { const obsvIndexP = new ObservabilityIndexPatterns(data); - let reportType: DataType = 'apm'; - if (firstSeries?.rt) { - reportType = ReportToDataTypeMap[firstSeries?.rt]; - } - return obsvIndexP.getIndexPattern(reportType); - }, [firstSeries?.rt, data]); + return obsvIndexP.getIndexPattern(dataType); + }, [dataType, data]); if (error) { throw error; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index 274542380c137..555b21618c4b2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -11,12 +11,11 @@ import { LensAttributes } from '../configurations/lens_attributes'; import { useUrlStorage } from './use_url_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { DataSeries, SeriesUrl, UrlFilter } from '../types'; +import { useIndexPatternContext } from './use_default_index_pattern'; interface Props { seriesId: string; - indexPattern?: IndexPattern | null; } export const getFiltersFromDefs = ( @@ -39,12 +38,12 @@ export const getFiltersFromDefs = ( export const useLensAttributes = ({ seriesId, - indexPattern, }: Props): TypedLensByValueInput['attributes'] | null => { const { series } = useUrlStorage(seriesId); - const { breakdown, seriesType, metric: metricType, reportType, reportDefinitions = {} } = - series ?? {}; + const { breakdown, seriesType, operationType, reportType, reportDefinitions = {} } = series ?? {}; + + const { indexPattern } = useIndexPatternContext(); return useMemo(() => { if (!indexPattern || !reportType) { @@ -66,7 +65,7 @@ export const useLensAttributes = ({ dataViewConfig, seriesType, filters, - metricType, + operationType, reportDefinitions ); @@ -79,7 +78,7 @@ export const useLensAttributes = ({ indexPattern, breakdown, seriesType, - metricType, + operationType, reportType, reportDefinitions, seriesId, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx index 6256b3b134f8c..a4fe15025245a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx @@ -26,9 +26,9 @@ export function UrlStorageContextProvider({ } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { - const { mt, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; + const { op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; return { - metric: mt, + operationType: op, reportType: rt!, seriesType: st, breakdown: bd, @@ -40,7 +40,7 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { } interface ShortUrlSeries { - [URL_KEYS.METRIC_TYPE]?: OperationType; + [URL_KEYS.OPERATION_TYPE]?: OperationType; [URL_KEYS.REPORT_TYPE]?: ReportViewTypeId; [URL_KEYS.SERIES_TYPE]?: SeriesType; [URL_KEYS.BREAK_DOWN]?: string; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx similarity index 74% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx index f291d0de4dac0..bac935dbecbe7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { SeriesChartTypes, XYChartTypes } from './chart_types'; import { mockUrlStorage, render } from '../../rtl_helpers'; +import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; -describe.skip('SeriesChartTypes', function () { +describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { mockUrlStorage({}); - render(); + render(); await waitFor(() => { screen.getByText(/chart type/i); @@ -24,7 +24,7 @@ describe.skip('SeriesChartTypes', function () { it('should call set series on change', async function () { const { setSeries } = mockUrlStorage({}); - render(); + render(); await waitFor(() => { screen.getByText(/chart type/i); @@ -42,11 +42,11 @@ describe.skip('SeriesChartTypes', function () { expect(setSeries).toHaveBeenCalledTimes(3); }); - describe('XYChartTypes', function () { + describe('XYChartTypesSelect', function () { it('should render properly', async function () { mockUrlStorage({}); - render(); + render(); await waitFor(() => { screen.getByText(/chart type/i); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx new file mode 100644 index 0000000000000..029c39df13aad --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; +import { useFetcher } from '../../../../..'; +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { SeriesType } from '../../../../../../../lens/public'; + +export function SeriesChartTypesSelect({ + seriesId, + defaultChartType, +}: { + seriesId: string; + defaultChartType: SeriesType; +}) { + const { series, setSeries, allSeries } = useUrlStorage(seriesId); + + const seriesType = series?.seriesType ?? defaultChartType; + + const onChange = (value: SeriesType) => { + Object.keys(allSeries).forEach((seriesKey) => { + const seriesN = allSeries[seriesKey]; + + setSeries(seriesKey, { ...seriesN, seriesType: value }); + }); + }; + + return ( + + ); +} + +export interface XYChartTypesProps { + label?: string; + value: SeriesType; + includeChartTypes?: SeriesType[]; + excludeChartTypes?: SeriesType[]; + onChange: (value: SeriesType) => void; +} + +export function XYChartTypesSelect({ + onChange, + value, + includeChartTypes, + excludeChartTypes, +}: XYChartTypesProps) { + const { + services: { lens }, + } = useKibana(); + + const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]); + + let vizTypes = data ?? []; + + if ((excludeChartTypes ?? []).length > 0) { + vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id as SeriesType)); + } + + if ((includeChartTypes ?? []).length > 0) { + vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id as SeriesType)); + } + + const options = (vizTypes ?? []).map(({ id, fullLabel, label, icon }) => { + const LabelWithIcon = ( + + + + + {fullLabel || label} + + ); + return { + value: id as SeriesType, + inputDisplay: LabelWithIcon, + dropdownDisplay: LabelWithIcon, + }; + }); + + return ( + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index 039cdfc9b73f5..41b9f7d22ba00 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -32,7 +32,7 @@ describe('DataTypesCol', function () { }); it('should set series on change on already selected', function () { - const { setSeries } = mockUrlStorage({ + const { removeSeries } = mockUrlStorage({ data: { [NEW_SERIES_KEY]: { dataType: 'synthetics', @@ -54,6 +54,6 @@ describe('DataTypesCol', function () { fireEvent.click(button); // undefined on click selected - expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: undefined }); + expect(removeSeries).toHaveBeenCalledWith('newSeriesKey'); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index b6464bbe3c6ed..d7e90d34a2596 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -20,15 +20,19 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [ ]; export function DataTypesCol() { - const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY); + const { series, setSeries, removeSeries } = useUrlStorage(NEW_SERIES_KEY); - const { loadIndexPattern } = useIndexPatternContext(); + const { loadIndexPattern, indexPattern } = useIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { if (dataType) { loadIndexPattern(dataType); } - setSeries(NEW_SERIES_KEY, { dataType } as any); + if (!dataType) { + removeSeries(NEW_SERIES_KEY); + } else { + setSeries(NEW_SERIES_KEY, { dataType } as any); + } }; const selectedDataType = series.dataType; @@ -43,6 +47,8 @@ export function DataTypesCol() { iconType="arrowRight" color={selectedDataType === dataTypeId ? 'primary' : 'text'} fill={selectedDataType === dataTypeId} + isDisabled={!indexPattern} + isLoading={!indexPattern && selectedDataType === dataTypeId} onClick={() => { onDataTypeChange(dataTypeId === selectedDataType ? undefined : dataTypeId); }} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx new file mode 100644 index 0000000000000..e05f91b4bb0bd --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { mockUrlStorage, render } from '../../rtl_helpers'; +import { OperationTypeSelect } from './operation_type_select'; + +describe('OperationTypeSelect', function () { + it('should render properly', function () { + render(); + + screen.getByText('Select an option: , is selected'); + }); + + it('should display selected value', function () { + mockUrlStorage({ + data: { + 'performance-distribution': { + reportType: 'kpi', + operationType: 'median', + time: { from: 'now-15m', to: 'now' }, + }, + }, + }); + + render(); + + screen.getByText('Median'); + }); + + it('should call set series on change', function () { + const { setSeries } = mockUrlStorage({ + data: { + 'series-id': { + reportType: 'kpi', + operationType: 'median', + time: { from: 'now-15m', to: 'now' }, + }, + }, + }); + + render(); + + fireEvent.click(screen.getByTestId('operationTypeSelect')); + + expect(setSeries).toHaveBeenCalledWith('series-id', { + operationType: 'median', + reportType: 'kpi', + time: { from: 'now-15m', to: 'now' }, + }); + + fireEvent.click(screen.getByText('95th Percentile')); + expect(setSeries).toHaveBeenCalledWith('series-id', { + operationType: '95th', + reportType: 'kpi', + time: { from: 'now-15m', to: 'now' }, + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx new file mode 100644 index 0000000000000..46167af0b244a --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSuperSelect } from '@elastic/eui'; + +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { OperationType } from '../../../../../../../lens/public'; + +export function OperationTypeSelect({ + seriesId, + defaultOperationType, +}: { + seriesId: string; + defaultOperationType?: OperationType; +}) { + const { series, setSeries } = useUrlStorage(seriesId); + + const operationType = series?.operationType; + + const onChange = (value: OperationType) => { + setSeries(seriesId, { ...series, operationType: value }); + }; + + useEffect(() => { + setSeries(seriesId, { ...series, operationType: operationType || defaultOperationType }); + }, [defaultOperationType, seriesId, operationType, setSeries, series]); + + const options = [ + { + value: 'average' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.average', { + defaultMessage: 'Average', + }), + }, + { + value: 'median' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.median', { + defaultMessage: 'Median', + }), + }, + { + value: '75th' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.75thPercentile', { + defaultMessage: '75th Percentile', + }), + }, + { + value: '90th' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.90thPercentile', { + defaultMessage: '90th Percentile', + }), + }, + { + value: '95th' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.95thPercentile', { + defaultMessage: '95th Percentile', + }), + }, + { + value: '99th' as OperationType, + inputDisplay: i18n.translate('xpack.observability.expView.operationType.99thPercentile', { + defaultMessage: '99th Percentile', + }), + }, + ]; + + return ( + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index b907efb57d5c2..a386b73a8f917 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -12,6 +12,8 @@ import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; import { CustomReportField } from '../custom_report_field'; import FieldValueSuggestions from '../../../field_value_suggestions'; import { DataSeries } from '../../types'; +import { SeriesChartTypesSelect } from './chart_types'; +import { OperationTypeSelect } from './operation_type_select'; export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSeries }) { const { indexPattern } = useIndexPatternContext(); @@ -20,7 +22,14 @@ export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSe const { reportDefinitions: rtd = {} } = series; - const { reportDefinitions, labels, filters } = dataViewSeries; + const { + reportDefinitions, + labels, + filters, + defaultSeriesType, + hasOperationType, + yAxisColumn, + } = dataViewSeries; const onChange = (field: string, value?: string) => { if (!value) { @@ -91,6 +100,17 @@ export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSe )} ))} + + + + {hasOperationType && ( + + + + )} ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 567e2654130e8..f845bf9885af9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -10,6 +10,7 @@ import { fireEvent, screen } from '@testing-library/react'; import { mockUrlStorage, render } from '../../rtl_helpers'; import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; import { ReportTypes } from '../series_builder'; +import { DEFAULT_TIME } from '../../configurations/constants'; describe('ReportTypesCol', function () { it('should render properly', function () { @@ -60,6 +61,9 @@ describe('ReportTypesCol', function () { fireEvent.click(button); // undefined on click selected - expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'synthetics' }); + expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { + dataType: 'synthetics', + time: DEFAULT_TIME, + }); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index a473ddb570526..a8f98b98026b6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { ReportViewTypeId, SeriesUrl } from '../../types'; import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { DEFAULT_TIME } from '../../configurations/constants'; +import { useIndexPatternContext } from '../../hooks/use_default_index_pattern'; interface Props { reportTypes: Array<{ id: ReportViewTypeId; label: string }>; @@ -21,6 +23,8 @@ export function ReportTypesCol({ reportTypes }: Props) { setSeries, } = useUrlStorage(NEW_SERIES_KEY); + const { indexPattern } = useIndexPatternContext(); + return reportTypes?.length > 0 ? ( {reportTypes.map(({ id: reportType, label }) => ( @@ -31,16 +35,19 @@ export function ReportTypesCol({ reportTypes }: Props) { iconType="arrowRight" color={selectedReportType === reportType ? 'primary' : 'text'} fill={selectedReportType === reportType} + isDisabled={!indexPattern} onClick={() => { if (reportType === selectedReportType) { setSeries(NEW_SERIES_KEY, { dataType: restSeries.dataType, + time: DEFAULT_TIME, } as SeriesUrl); } else { setSeries(NEW_SERIES_KEY, { ...restSeries, reportType, reportDefinitions: {}, + time: restSeries?.time ?? DEFAULT_TIME, }); } }} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 053f301529635..2280109fdacdf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -49,7 +49,14 @@ export const ReportTypes: Record { @@ -145,7 +154,7 @@ export function SeriesBuilder() { columns={columns} cellProps={{ style: { borderRight: '1px solid #d3dae6' } }} /> - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx index 922d33ffd39ac..960c2978287bc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx @@ -10,6 +10,7 @@ import React, { useEffect } from 'react'; import { useHasData } from '../../../../hooks/use_has_data'; import { useUrlStorage } from '../hooks/use_url_storage'; import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; +import { DEFAULT_TIME } from '../configurations/constants'; export interface TimePickerTime { from: string; @@ -38,7 +39,7 @@ export function SeriesDatePicker({ seriesId }: Props) { useEffect(() => { if (!series || !series.time) { - setSeries(seriesId, { ...series, time: { from: 'now-5h', to: 'now' } }); + setSeries(seriesId, { ...series, time: DEFAULT_TIME }); } }, [seriesId, series, setSeries]); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index acc9ba9658a08..8fe1d5ed9f2ac 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers'; import { fireEvent, waitFor } from '@testing-library/react'; import { SeriesDatePicker } from './index'; +import { DEFAULT_TIME } from '../configurations/constants'; describe('SeriesDatePicker', function () { it('should render properly', function () { @@ -40,7 +41,7 @@ describe('SeriesDatePicker', function () { expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', reportType: 'upp', - time: { from: 'now-5h', to: 'now' }, + time: DEFAULT_TIME, }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx index c6209381a4da1..fe54262e13844 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DataSeries } from '../../types'; -import { SeriesChartTypes } from './chart_types'; -import { MetricSelection } from './metric_selection'; +import { OperationTypeSelect } from '../../series_builder/columns/operation_type_select'; +import { SeriesChartTypesSelect } from '../../series_builder/columns/chart_types'; interface Props { series: DataSeries; @@ -17,13 +17,13 @@ interface Props { export function ActionsCol({ series }: Props) { return ( - + - + - {series.hasMetricType && ( + {series.hasOperationType && ( - + )} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx deleted file mode 100644 index f83630cff414a..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx +++ /dev/null @@ -1,149 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; - -import { - EuiButton, - EuiButtonGroup, - EuiButtonIcon, - EuiLoadingSpinner, - EuiPopover, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; -import { useFetcher } from '../../../../..'; -import { useUrlStorage } from '../../hooks/use_url_storage'; -import { SeriesType } from '../../../../../../../lens/public'; - -export function SeriesChartTypes({ - seriesId, - defaultChartType, -}: { - seriesId: string; - defaultChartType: SeriesType; -}) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); - - const seriesType = series?.seriesType ?? defaultChartType; - - const onChange = (value: SeriesType) => { - Object.keys(allSeries).forEach((seriesKey) => { - const seriesN = allSeries[seriesKey]; - - setSeries(seriesKey, { ...seriesN, seriesType: value }); - }); - }; - - return ( - - ); -} - -export interface XYChartTypesProps { - onChange: (value: SeriesType) => void; - value: SeriesType; - label?: string; - includeChartTypes?: string[]; - excludeChartTypes?: string[]; -} - -export function XYChartTypes({ - onChange, - value, - label, - includeChartTypes, - excludeChartTypes, -}: XYChartTypesProps) { - const [isOpen, setIsOpen] = useState(false); - - const { - services: { lens }, - } = useKibana(); - - const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]); - - let vizTypes = data ?? []; - - if ((excludeChartTypes ?? []).length > 0) { - vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id)); - } - - if ((includeChartTypes ?? []).length > 0) { - vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id)); - } - - return loading ? ( - - ) : ( - id === value)?.icon} - onClick={() => { - setIsOpen((prevState) => !prevState); - }} - > - {label} - - ) : ( - id === value)?.label} - iconType={vizTypes.find(({ id }) => id === value)?.icon!} - onClick={() => { - setIsOpen((prevState) => !prevState); - }} - /> - ) - } - closePopover={() => setIsOpen(false)} - > - ({ - id: t.id, - label: t.label, - title: t.label, - iconType: t.icon || 'empty', - 'data-test-subj': `lnsXY_seriesType-${t.id}`, - }))} - idSelected={value} - onChange={(valueN: string) => { - onChange(valueN as SeriesType); - }} - /> - - ); -} - -const ButtonGroup = styled(EuiButtonGroup)` - &&& { - .euiButtonGroupButton-isSelected { - background-color: #a5a9b1 !important; - } - } -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx deleted file mode 100644 index ced04f0a59c8c..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx +++ /dev/null @@ -1,112 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; -import { MetricSelection } from './metric_selection'; - -describe('MetricSelection', function () { - it('should render properly', function () { - render(); - - screen.getByText('Average'); - }); - - it('should display selected value', function () { - mockUrlStorage({ - data: { - 'performance-distribution': { - reportType: 'kpi', - metric: 'median', - time: { from: 'now-15m', to: 'now' }, - }, - }, - }); - - render(); - - screen.getByText('Median'); - }); - - it('should be disabled on disabled state', function () { - render(); - - const btn = screen.getByRole('button'); - - expect(btn.classList).toContain('euiButton-isDisabled'); - }); - - it('should call set series on change', function () { - const { setSeries } = mockUrlStorage({ - data: { - 'performance-distribution': { - reportType: 'kpi', - metric: 'median', - time: { from: 'now-15m', to: 'now' }, - }, - }, - }); - - render(); - - fireEvent.click(screen.getByText('Median')); - - screen.getByText('Chart metric group'); - - fireEvent.click(screen.getByText('95th Percentile')); - - expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', { - metric: '95th', - reportType: 'kpi', - time: { from: 'now-15m', to: 'now' }, - }); - // FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times - // This should be one https://github.com/elastic/eui/issues/4629 - expect(setSeries).toHaveBeenCalledTimes(3); - }); - - it('should call set series on change for all series', function () { - const { setSeries } = mockUrlStorage({ - data: { - 'page-views': { - reportType: 'kpi', - metric: 'median', - time: { from: 'now-15m', to: 'now' }, - }, - 'performance-distribution': { - reportType: 'kpi', - metric: 'median', - time: { from: 'now-15m', to: 'now' }, - }, - }, - }); - - render(); - - fireEvent.click(screen.getByText('Median')); - - screen.getByText('Chart metric group'); - - fireEvent.click(screen.getByText('95th Percentile')); - - expect(setSeries).toHaveBeenNthCalledWith(1, 'page-views', { - metric: '95th', - reportType: 'kpi', - time: { from: 'now-15m', to: 'now' }, - }); - - expect(setSeries).toHaveBeenNthCalledWith(2, 'performance-distribution', { - metric: '95th', - reportType: 'kpi', - time: { from: 'now-15m', to: 'now' }, - }); - // FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times - // This should be one https://github.com/elastic/eui/issues/4629 - expect(setSeries).toHaveBeenCalledTimes(6); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx deleted file mode 100644 index fa4202d2c30ad..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiButtonGroup, EuiPopover } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; -import { OperationType } from '../../../../../../../lens/public'; - -const toggleButtons = [ - { - id: `average`, - label: i18n.translate('xpack.observability.expView.metricsSelect.average', { - defaultMessage: 'Average', - }), - }, - { - id: `median`, - label: i18n.translate('xpack.observability.expView.metricsSelect.median', { - defaultMessage: 'Median', - }), - }, - { - id: `95th`, - label: i18n.translate('xpack.observability.expView.metricsSelect.9thPercentile', { - defaultMessage: '95th Percentile', - }), - }, - { - id: `99th`, - label: i18n.translate('xpack.observability.expView.metricsSelect.99thPercentile', { - defaultMessage: '99th Percentile', - }), - }, -]; - -export function MetricSelection({ - seriesId, - isDisabled, -}: { - seriesId: string; - isDisabled: boolean; -}) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); - - const [isOpen, setIsOpen] = useState(false); - - const [toggleIdSelected, setToggleIdSelected] = useState(series?.metric ?? 'average'); - - const onChange = (optionId: OperationType) => { - setToggleIdSelected(optionId); - - Object.keys(allSeries).forEach((seriesKey) => { - const seriesN = allSeries[seriesKey]; - - setSeries(seriesKey, { ...seriesN, metric: optionId }); - }); - }; - const button = ( - setIsOpen((prevState) => !prevState)} - size="s" - color="text" - isDisabled={isDisabled} - > - {toggleButtons.find(({ id }) => id === toggleIdSelected)!.label} - - ); - - return ( - setIsOpen(false)}> - onChange(id as OperationType)} - /> - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index d673fc4d6f6ee..141dcecd0ba5b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -9,9 +9,9 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { LastValueIndexPatternColumn, DateHistogramIndexPatternColumn, + FieldBasedIndexPatternColumn, SeriesType, OperationType, - IndexPatternColumn, } from '../../../../../lens/public'; import { PersistableFilter } from '../../../../../lens/common'; @@ -41,14 +41,19 @@ export interface ReportDefinition { required?: boolean; custom?: boolean; defaultValue?: string; - options?: Array<{ field: string; label: string; description?: string }>; + options?: Array<{ + field: string; + label: string; + description?: string; + columnType?: 'range' | 'operation'; + }>; } export interface DataSeries { reportType: ReportViewType; id: string; xAxisColumn: Partial | Partial; - yAxisColumn: Partial; + yAxisColumn: Partial; breakdowns: string[]; defaultSeriesType: SeriesType; @@ -57,7 +62,7 @@ export interface DataSeries { filters?: PersistableFilter[]; reportDefinitions: ReportDefinition[]; labels: Record; - hasMetricType: boolean; + hasOperationType: boolean; palette?: PaletteOutput; } @@ -70,7 +75,7 @@ export interface SeriesUrl { filters?: UrlFilter[]; seriesType?: SeriesType; reportType: ReportViewTypeId; - metric?: OperationType; + operationType?: OperationType; dataType?: AppDataType; reportDefinitions?: Record; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts index e0a2941b24d3c..527ef48364d22 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts @@ -47,12 +47,16 @@ const appToPatternMap: Record = { }; export function isParamsSame(param1: IFieldFormat['_params'], param2: FieldFormatParams) { - return ( + const isSame = param1?.inputFormat === param2?.inputFormat && param1?.outputFormat === param2?.outputFormat && - param1?.showSuffix === param2?.showSuffix && - param2?.outputPrecision === param1?.outputPrecision - ); + param1?.showSuffix === param2?.showSuffix; + + if (param2.outputPrecision !== undefined) { + return param2?.outputPrecision === param1?.outputPrecision && isSame; + } + + return isSame; } export class ObservabilityIndexPatterns { diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index cb9878f578885..c59b4dbe373dd 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -6,29 +6,30 @@ */ import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { pickWithPatterns } from '../../rule_registry/server'; import { ObservabilityConfig } from '.'; import { bootstrapAnnotations, - ScopedAnnotationsClient, ScopedAnnotationsClientFactory, AnnotationsAPI, } from './lib/annotations/bootstrap_annotations'; +import type { RuleRegistryPluginSetupContract } from '../../rule_registry/server'; import { uiSettings } from './ui_settings'; +import { ecsFieldMap } from '../../rule_registry/server'; -type LazyScopedAnnotationsClientFactory = ( - ...args: Parameters -) => Promise; - -export interface ObservabilityPluginSetup { - getScopedAnnotationsClient: LazyScopedAnnotationsClientFactory; -} +export type ObservabilityPluginSetup = ReturnType; export class ObservabilityPlugin implements Plugin { constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; } - public setup(core: CoreSetup, plugins: {}): ObservabilityPluginSetup { + public setup( + core: CoreSetup, + plugins: { + ruleRegistry: RuleRegistryPluginSetupContract; + } + ) { const config = this.initContext.config.get(); let annotationsApiPromise: Promise | undefined; @@ -48,10 +49,16 @@ export class ObservabilityPlugin implements Plugin { } return { - getScopedAnnotationsClient: async (...args) => { + getScopedAnnotationsClient: async (...args: Parameters) => { const api = await annotationsApiPromise; return api?.getScopedAnnotationsClient(...args); }, + ruleRegistry: plugins.ruleRegistry.create({ + name: 'observability', + fieldMap: { + ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), + }, + }), }; } diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index f55ae640a8026..bd37bc09bc130 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -23,6 +23,7 @@ { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../translations/tsconfig.json" } diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js deleted file mode 100644 index 38672b4d59a20..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js +++ /dev/null @@ -1,43 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterAdd } from '../../../public/application/sections/remote_cluster_add'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - }, -}; - -const initTestBed = registerTestBed(RemoteClusterAdd, testBedConfig); - -export const setup = (props) => { - const testBed = initTestBed(props); - - // User actions - const clickSaveForm = async () => { - await act(async () => { - testBed.find('remoteClusterFormSaveButton').simulate('click'); - }); - - testBed.component.update(); - }; - - return { - ...testBed, - actions: { - clickSaveForm, - }, - }; -}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx new file mode 100644 index 0000000000000..a47e6c023a161 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { registerTestBed } from '@kbn/test/jest'; + +import { RemoteClusterAdd } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +const ComponentWithContext = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return ( + + + + ); +}; + +const testBedConfig = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + }, + defaultProps: { isCloudEnabled }, + }; +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig({ isCloudEnabled }))(); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js deleted file mode 100644 index 40abde35835f0..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js +++ /dev/null @@ -1,230 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { setupEnvironment } from '../helpers'; -import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; -import { setup } from './remote_clusters_add.helpers'; - -describe('Create Remote cluster', () => { - describe('on component mount', () => { - let find; - let exists; - let actions; - let form; - let server; - let component; - - beforeAll(() => { - ({ server } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); - }); - - beforeEach(async () => { - await act(async () => { - ({ form, exists, find, actions, component } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Add remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - test('should have a toggle to Skip unavailable remote cluster', () => { - expect(exists('remoteClusterFormSkipUnavailableFormToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - false - ); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormSkipUnavailableFormToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(true); - }); - - test('should have a toggle to enable "proxy" mode for a remote cluster', () => { - expect(exists('remoteClusterFormConnectionModeToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(false); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(true); - }); - - test('should display errors and disable the save button when clicking "save" without filling the form', async () => { - expect(exists('remoteClusterFormGlobalError')).toBe(false); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(false); - - await actions.clickSaveForm(); - - expect(exists('remoteClusterFormGlobalError')).toBe(true); - expect(form.getErrorsMessages()).toEqual([ - 'Name is required.', - 'At least one seed node is required.', - ]); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(true); - }); - }); - - describe('form validation', () => { - describe('remote cluster name', () => { - let component; - let actions; - let form; - - beforeEach(async () => { - await act(async () => { - ({ component, form, actions } = setup()); - }); - - component.update(); - }); - - test('should not allow spaces', async () => { - form.setInputValue('remoteClusterFormNameInput', 'with space'); - - await actions.clickSaveForm(); - - expect(form.getErrorsMessages()).toContain('Spaces are not allowed in the name.'); - }); - - test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { - const expectInvalidChar = (char) => { - if (char === '-' || char === '_') { - return; - } - - try { - form.setInputValue('remoteClusterFormNameInput', `with${char}`); - - expect(form.getErrorsMessages()).toContain( - `Remove the character ${char} from the name.` - ); - } catch { - throw Error(`Char "${char}" expected invalid but was allowed`); - } - }; - - await actions.clickSaveForm(); // display form errors - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); - }); - }); - - describe('seeds', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - form.setInputValue('remoteClusterFormNameInput', 'remote_cluster_test'); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setComboBoxValue('remoteClusterFormSeedsInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - - describe('proxy address', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - act(() => { - // Enable "proxy" mode - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setInputValue('remoteClusterFormProxyAddressInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts new file mode 100644 index 0000000000000..0727bc0c9ba2d --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SinonFakeServer } from 'sinon'; +import { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, RemoteClustersActions } from '../helpers'; +import { setup } from './remote_clusters_add.helpers'; +import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; + +const notInArray = (array: string[]) => (value: string) => array.indexOf(value) < 0; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +let server: SinonFakeServer; + +describe('Create Remote cluster', () => { + beforeAll(() => { + ({ server } = setupEnvironment()); + }); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + component.update(); + }); + + describe('on component mount', () => { + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Add remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + test('should have a toggle to Skip unavailable remote cluster', () => { + expect(actions.skipUnavailableSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.skipUnavailableSwitch.isChecked()).toBe(false); + + actions.skipUnavailableSwitch.toggle(); + + expect(actions.skipUnavailableSwitch.isChecked()).toBe(true); + }); + + describe('on prem', () => { + test('should have a toggle to enable "proxy" mode for a remote cluster', () => { + expect(actions.connectionModeSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.connectionModeSwitch.isChecked()).toBe(false); + + actions.connectionModeSwitch.toggle(); + + expect(actions.connectionModeSwitch.isChecked()).toBe(true); + }); + + test('server name has optional label', () => { + actions.connectionModeSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name (optional)'); + }); + + test('should display errors and disable the save button when clicking "save" without filling the form', async () => { + expect(actions.globalErrorExists()).toBe(false); + expect(actions.saveButton.isDisabled()).toBe(false); + + await actions.saveButton.click(); + + expect(actions.globalErrorExists()).toBe(true); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + // seeds input is switched on by default on prem and is required + 'At least one seed node is required.', + ]); + expect(actions.saveButton.isDisabled()).toBe(true); + }); + + test('renders no switch for cloud url input and proxy address + server name input modes', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(false); + }); + }); + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('renders a switch between cloud url input and proxy address + server name input for proxy connection', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(true); + }); + + test('renders no switch between sniff and proxy modes', () => { + expect(actions.connectionModeSwitch.exists()).toBe(false); + }); + test('defaults to cloud url input for proxy connection', () => { + expect(actions.cloudUrlSwitch.isChecked()).toBe(false); + }); + test('server name has no optional label', () => { + actions.cloudUrlSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name'); + }); + }); + }); + describe('form validation', () => { + describe('remote cluster name', () => { + test('should not allow spaces', async () => { + actions.nameInput.setValue('with space'); + + await actions.saveButton.click(); + + expect(actions.getErrorMessages()).toContain('Spaces are not allowed in the name.'); + }); + + test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { + const expectInvalidChar = (char: string) => { + if (char === '-' || char === '_') { + return; + } + + try { + actions.nameInput.setValue(`with${char}`); + + expect(actions.getErrorMessages()).toContain( + `Remove the character ${char} from the name.` + ); + } catch { + throw Error(`Char "${char}" expected invalid but was allowed`); + } + }; + + await actions.saveButton.click(); // display form errors + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); + }); + }); + + describe('proxy address', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.connectionModeSwitch.toggle(); + }); + + test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.proxyAddressInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.proxyAddressInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.proxyAddressInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + describe('on prem', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.nameInput.setValue('remote_cluster_test'); + }); + + describe('seeds', () => { + test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.seedsInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.seedsInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.seedsInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + test('server name is optional (proxy connection)', () => { + actions.connectionModeSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual(['A proxy address is required.']); + }); + }); + + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('cloud url is required since cloud url input is enabled by default', () => { + actions.saveButton.click(); + expect(actions.getErrorMessages()).toContain('A url is required.'); + }); + + test('proxy address and server name are required when cloud url input is disabled', () => { + actions.cloudUrlSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + 'A proxy address is required.', + 'A server name is required.', + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts similarity index 100% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js deleted file mode 100644 index 094fb5056e983..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js +++ /dev/null @@ -1,34 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterEdit } from '../../../public/application/sections/remote_cluster_edit'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; - -export const REMOTE_CLUSTER_EDIT = { - name: REMOTE_CLUSTER_EDIT_NAME, - seeds: ['localhost:9400'], - skipUnavailable: true, -}; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - // The remote cluster name to edit is read from the router ":id" param - // so we first set it in our initial entries - initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], - // and then we declarae the :id param on the component route path - componentRoutePath: '/:name', - }, -}; - -export const setup = registerTestBed(RemoteClusterEdit, testBedConfig); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx new file mode 100644 index 0000000000000..2259396bf33f2 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerTestBed, TestBedConfig } from '@kbn/test/jest'; + +import React from 'react'; +import { RemoteClusterEdit } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; + +export const REMOTE_CLUSTER_EDIT = { + name: REMOTE_CLUSTER_EDIT_NAME, + seeds: ['localhost:9400'], + skipUnavailable: true, +}; + +const ComponentWithContext = (props: { isCloudEnabled: boolean }) => { + const { isCloudEnabled, ...rest } = props; + return ( + + + + ); +}; + +const testBedConfig: TestBedConfig = { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + // The remote cluster name to edit is read from the router ":id" param + // so we first set it in our initial entries + initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], + // and then we declare the :id param on the component route path + componentRoutePath: '/:name', + }, +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig)({ isCloudEnabled }); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js deleted file mode 100644 index 19dd468cb76c5..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js +++ /dev/null @@ -1,80 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; -import { setupEnvironment } from '../helpers'; -import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; -import { - setup, - REMOTE_CLUSTER_EDIT, - REMOTE_CLUSTER_EDIT_NAME, -} from './remote_clusters_edit.helpers'; - -describe('Edit Remote cluster', () => { - let component; - let find; - let exists; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); - - httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); - - beforeEach(async () => { - await act(async () => { - ({ component, find, exists } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Edit remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - /** - * As the "edit" remote cluster component uses the same form underneath that - * the "create" remote cluster, we won't test it again but simply make sure that - * the form component is indeed shared between the 2 app sections. - */ - test('should use the same Form component as the "" component', async () => { - let addRemoteClusterTestBed; - - await act(async () => { - addRemoteClusterTestBed = setupRemoteClustersAdd(); - }); - - addRemoteClusterTestBed.component.update(); - - const formEdit = component.find(RemoteClusterForm); - const formAdd = addRemoteClusterTestBed.component.find(RemoteClusterForm); - - expect(formEdit.length).toBe(1); - expect(formAdd.length).toBe(1); - }); - - test('should populate the form fields with the values from the remote cluster loaded', () => { - expect(find('remoteClusterFormNameInput').props().value).toBe(REMOTE_CLUSTER_EDIT_NAME); - expect(find('remoteClusterFormSeedsInput').text()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - REMOTE_CLUSTER_EDIT.skipUnavailable - ); - }); - - test('should disable the form name input', () => { - expect(find('remoteClusterFormNameInput').props().disabled).toBe(true); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx new file mode 100644 index 0000000000000..2913de94bc2dd --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; + +import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; +import { RemoteClustersActions, setupEnvironment } from '../helpers'; +import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; +import { + setup, + REMOTE_CLUSTER_EDIT, + REMOTE_CLUSTER_EDIT_NAME, +} from './remote_clusters_edit.helpers'; +import { Cluster } from '../../../common/lib'; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +const { server, httpRequestsMockHelpers } = setupEnvironment(); + +describe('Edit Remote cluster', () => { + afterAll(() => { + server.restore(); + }); + + httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); + + beforeEach(async () => { + await act(async () => { + ({ component, actions } = await setup()); + }); + component.update(); + }); + + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Edit remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + /** + * As the "edit" remote cluster component uses the same form underneath that + * the "create" remote cluster, we won't test it again but simply make sure that + * the form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" component', async () => { + let addRemoteClusterTestBed: TestBed; + + await act(async () => { + addRemoteClusterTestBed = await setupRemoteClustersAdd(); + }); + + addRemoteClusterTestBed!.component.update(); + + const formEdit = component.find(RemoteClusterForm); + const formAdd = addRemoteClusterTestBed!.component.find(RemoteClusterForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should populate the form fields with the values from the remote cluster loaded', () => { + expect(actions.nameInput.getValue()).toBe(REMOTE_CLUSTER_EDIT_NAME); + // seeds input for sniff connection is not shown on Cloud + expect(actions.seedsInput.getValue()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); + expect(actions.skipUnavailableSwitch.isChecked()).toBe(REMOTE_CLUSTER_EDIT.skipUnavailable); + }); + + test('should disable the form name input', () => { + expect(actions.nameInput.isDisabled()).toBe(true); + }); + + describe('on cloud', () => { + const cloudUrl = 'cloud-url'; + const defaultCloudPort = '9400'; + test('existing cluster that defaults to cloud url (default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(true); + expect(actions.cloudUrlInput.getValue()).toBe(cloudUrl); + }); + + test('existing cluster that defaults to manual input (non-default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:9500`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + + test('existing cluster that defaults to manual input (proxy address is different from server name)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: 'another-value', + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts similarity index 63% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts index 304ec51986aba..3ebe3ab5738d6 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts @@ -5,25 +5,24 @@ * 2.0. */ -import sinon from 'sinon'; +import sinon, { SinonFakeServer } from 'sinon'; +import { Cluster } from '../../../common/lib'; // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server) => { - const mockResponse = (response) => [ +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const mockResponse = (response: Cluster[] | { itemsDeleted: string[]; errors: string[] }) => [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(response), ]; - const setLoadRemoteClustersResponse = (response) => { - server.respondWith('GET', '/api/remote_clusters', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + const setLoadRemoteClustersResponse = (response: Cluster[] = []) => { + server.respondWith('GET', '/api/remote_clusters', mockResponse(response)); }; - const setDeleteRemoteClusterResponse = (response) => { + const setDeleteRemoteClusterResponse = ( + response: { itemsDeleted: string[]; errors: string[] } = { itemsDeleted: [], errors: [] } + ) => { server.respondWith('DELETE', /api\/remote_clusters/, mockResponse(response)); }; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts similarity index 80% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts index 63084b21e3902..cf859ff6913f5 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { nextTick, getRandomString, findTestSubject } from '@kbn/test/jest'; export { setupEnvironment } from './setup_environment'; +export { createRemoteClustersActions, RemoteClustersActions } from './remote_clusters_actions'; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts new file mode 100644 index 0000000000000..ba0c424793838 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +export interface RemoteClustersActions { + docsButtonExists: () => boolean; + pageTitle: { + exists: () => boolean; + text: () => string; + }; + nameInput: { + setValue: (name: string) => void; + getValue: () => string; + isDisabled: () => boolean; + }; + skipUnavailableSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + connectionModeSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + cloudUrlSwitch: { + toggle: () => void; + exists: () => boolean; + isChecked: () => boolean; + }; + cloudUrlInput: { + exists: () => boolean; + getValue: () => string; + }; + seedsInput: { + setValue: (seed: string) => void; + getValue: () => string; + }; + proxyAddressInput: { + setValue: (proxyAddress: string) => void; + exists: () => boolean; + }; + serverNameInput: { + getLabel: () => string; + exists: () => boolean; + }; + saveButton: { + click: () => void; + isDisabled: () => boolean; + }; + getErrorMessages: () => string[]; + globalErrorExists: () => boolean; +} +export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersActions => { + const { form, exists, find, component } = testBed; + + const docsButtonExists = () => exists('remoteClusterDocsButton'); + const createPageTitleActions = () => { + const pageTitleSelector = 'remoteClusterPageTitle'; + return { + pageTitle: { + exists: () => exists(pageTitleSelector), + text: () => find(pageTitleSelector).text(), + }, + }; + }; + const createNameInputActions = () => { + const nameInputSelector = 'remoteClusterFormNameInput'; + return { + nameInput: { + setValue: (name: string) => form.setInputValue(nameInputSelector, name), + getValue: () => find(nameInputSelector).props().value, + isDisabled: () => find(nameInputSelector).props().disabled, + }, + }; + }; + + const createSkipUnavailableActions = () => { + const skipUnavailableToggleSelector = 'remoteClusterFormSkipUnavailableFormToggle'; + return { + skipUnavailableSwitch: { + exists: () => exists(skipUnavailableToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(skipUnavailableToggleSelector); + }); + component.update(); + }, + isChecked: () => find(skipUnavailableToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createConnectionModeActions = () => { + const connectionModeToggleSelector = 'remoteClusterFormConnectionModeToggle'; + return { + connectionModeSwitch: { + exists: () => exists(connectionModeToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(connectionModeToggleSelector); + }); + component.update(); + }, + isChecked: () => find(connectionModeToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createCloudUrlSwitchActions = () => { + const cloudUrlSelector = 'remoteClusterFormCloudUrlToggle'; + return { + cloudUrlSwitch: { + exists: () => exists(cloudUrlSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(cloudUrlSelector); + }); + component.update(); + }, + isChecked: () => find(cloudUrlSelector).props()['aria-checked'], + }, + }; + }; + + const createSeedsInputActions = () => { + const seedsInputSelector = 'remoteClusterFormSeedsInput'; + return { + seedsInput: { + setValue: (seed: string) => form.setComboBoxValue(seedsInputSelector, seed), + getValue: () => find(seedsInputSelector).text(), + }, + }; + }; + + const createProxyAddressActions = () => { + const proxyAddressSelector = 'remoteClusterFormProxyAddressInput'; + return { + proxyAddressInput: { + setValue: (proxyAddress: string) => form.setInputValue(proxyAddressSelector, proxyAddress), + exists: () => exists(proxyAddressSelector), + }, + }; + }; + + const createSaveButtonActions = () => { + const click = () => { + act(() => { + find('remoteClusterFormSaveButton').simulate('click'); + }); + + component.update(); + }; + const isDisabled = () => find('remoteClusterFormSaveButton').props().disabled; + return { saveButton: { click, isDisabled } }; + }; + + const createServerNameActions = () => { + const serverNameSelector = 'remoteClusterFormServerNameFormRow'; + return { + serverNameInput: { + getLabel: () => find('remoteClusterFormServerNameFormRow').find('label').text(), + exists: () => exists(serverNameSelector), + }, + }; + }; + + const globalErrorExists = () => exists('remoteClusterFormGlobalError'); + + const createCloudUrlInputActions = () => { + const cloudUrlInputSelector = 'remoteClusterFormCloudUrlInput'; + return { + cloudUrlInput: { + exists: () => exists(cloudUrlInputSelector), + getValue: () => find(cloudUrlInputSelector).props().value, + }, + }; + }; + return { + docsButtonExists, + ...createPageTitleActions(), + ...createNameInputActions(), + ...createSkipUnavailableActions(), + ...createConnectionModeActions(), + ...createCloudUrlSwitchActions(), + ...createSeedsInputActions(), + ...createCloudUrlInputActions(), + ...createProxyAddressActions(), + ...createServerNameActions(), + ...createSaveButtonActions(), + getErrorMessages: form.getErrorsMessages, + globalErrorExists, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts similarity index 95% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts index 97ad344a63cc4..084552c5e6abe 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts @@ -36,6 +36,8 @@ export const setupEnvironment = () => { notificationServiceMock.createSetupContract().toasts, fatalErrorsServiceMock.createSetupContract() ); + // This expects HttpSetup but we're giving it AxiosInstance. + // @ts-ignore initHttp(mockHttpClient); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/plugins/remote_clusters/public/application/app_context.tsx b/x-pack/plugins/remote_clusters/public/application/app_context.tsx index 7931001c6faee..528ec322f49e1 100644 --- a/x-pack/plugins/remote_clusters/public/application/app_context.tsx +++ b/x-pack/plugins/remote_clusters/public/application/app_context.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { createContext } from 'react'; +import React, { createContext, useContext } from 'react'; export interface Context { isCloudEnabled: boolean; + cloudBaseUrl: string; } export const AppContext = createContext({} as any); @@ -22,3 +23,10 @@ export const AppContextProvider = ({ }) => { return {children}; }; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('Cannot use outside of app context'); + + return ctx; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/index.d.ts b/x-pack/plugins/remote_clusters/public/application/index.d.ts index 167297cedf556..45f981b5f2bc5 100644 --- a/x-pack/plugins/remote_clusters/public/application/index.d.ts +++ b/x-pack/plugins/remote_clusters/public/application/index.d.ts @@ -12,7 +12,8 @@ export declare const renderApp: ( elem: HTMLElement | null, I18nContext: I18nStart['Context'], appDependencies: { - isCloudEnabled?: boolean; + isCloudEnabled: boolean; + cloudBaseUrl: string; }, history: ScopedHistory ) => ReturnType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap deleted file mode 100644 index 5f09193be90c2..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ /dev/null @@ -1,2017 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RemoteClusterForm proxy mode renders correct connection settings when user enables proxy mode 1`] = ` - - -
- - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Name - -

-
-
- -
- -
- - A unique name for the cluster. - -
-
-
-
-
-
- -
- - } - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - helpText={ - - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - Name can only contain letters, numbers, underscores, and dashes. - -
-
-
-
-
-
-
-
-
-
-
- - - - - } - onChange={[Function]} - /> - - - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Connection mode - -

-
-
- -
- -
- - Use seed nodes by default, or switch to proxy mode. - - -
-
- - } - onBlur={[Function]} - onChange={[Function]} - onFocus={[Function]} - > -
- - - - Use proxy mode - - -
-
-
-
-
-
-
-
-
-
-
- -
- - } - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - helpText={ - - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - The address to use for remote connections. - -
-
-
-
-
- - - , - } - } - /> - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - - , - } - } - > - A string sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. - - - - -
-
-
-
-
- - } - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - The number of socket connections to open per remote cluster. - -
-
-
-
-
-
-
-
-
-
-
- -

- - - , - "optionName": - - , - } - } - /> -

- - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Make remote cluster optional - -

-
-
- -
- -
-

- - - , - "optionName": - - , - } - } - > - A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable - - - Skip if unavailable - - - . - - - - -

-
-
-
-
-
-
- -
- -
-
- -
- - - Skip if unavailable - -
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - -
- -
- -
- -
- - - - - -
-
-
-
-
-
- -
- - - -
-
-
-
- -`; - -exports[`RemoteClusterForm renders untouched state 1`] = ` -Array [ -
-
-
-
-

- Name -

-
-
- A unique name for the cluster. -
-
-
-
-
-
- -
-
-
-
- -
-
-
- Name can only contain letters, numbers, underscores, and dashes. -
-
-
-
-
-
-
-
-
-

- Connection mode -

-
-
- Use seed nodes by default, or switch to proxy mode. -
-
-
- - - Use proxy mode - -
-
-
-
-
-
-
-
-
- -
-
- -
-
-
- -
-
-
-
- -
-
-
- The number of gateway nodes to connect to for this cluster. -
-
-
-
-
-
-
-
-
-

- Make remote cluster optional -

-
-
-

- A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable - - Skip if unavailable - - . - -

-
-
-
-
-
-
-
- - - Skip if unavailable - -
-
-
-
-
-
-
, -
, -
-
-
-
- -
-
-
-
- -
-
, -] -`; - -exports[`RemoteClusterForm validation renders invalid state and a global form error when the user tries to submit an invalid form 1`] = ` -Array [ -
-
- -
-
-
-
- -
-
-
- Name is required. -
-
- Name can only contain letters, numbers, underscores, and dashes. -
-
-
, -
-
- -
-
- -
, -
-
-
- - - Skip if unavailable - -
-
-
, -
, -] -`; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx new file mode 100644 index 0000000000000..1d4862ff094ce --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { useAppContext } from '../../../../app_context'; + +export const CloudUrlHelp: FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(false); + const { cloudBaseUrl } = useAppContext(); + return ( + + { + setIsOpen(!isOpen); + }} + > + + + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="upCenter" + > + + + + + + + + ), + elasticsearch: Elasticsearch, + }} + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx new file mode 100644 index 0000000000000..d06b4f111ec92 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiDescribedFormGroup, EuiTitle, EuiFormRow, EuiSwitch, EuiSpacer } from '@elastic/eui'; + +import { SNIFF_MODE, PROXY_MODE } from '../../../../../../common/constants'; +import { useAppContext } from '../../../../app_context'; + +import { ClusterErrors } from '../validators'; +import { SniffConnection } from './sniff_connection'; +import { ProxyConnection } from './proxy_connection'; +import { FormFields } from '../remote_cluster_form'; + +export interface Props { + fields: FormFields; + onFieldsChange: (fields: Partial) => void; + fieldsErrors: ClusterErrors; + areErrorsVisible: boolean; +} + +export const ConnectionMode: FunctionComponent = (props) => { + const { fields, onFieldsChange } = props; + const { mode, cloudUrlEnabled } = fields; + const { isCloudEnabled } = useAppContext(); + + return ( + +

+ +

+ + } + description={ + <> + {isCloudEnabled ? ( + <> + + + + } + checked={!cloudUrlEnabled} + data-test-subj="remoteClusterFormCloudUrlToggle" + onChange={(e) => onFieldsChange({ cloudUrlEnabled: !e.target.checked })} + /> + + + + ) : ( + <> + + + + } + checked={mode === PROXY_MODE} + data-test-subj="remoteClusterFormConnectionModeToggle" + onChange={(e) => + onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) + } + /> + + + )} + + } + fullWidth + > + {mode === SNIFF_MODE ? : } +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts similarity index 72% rename from x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts rename to x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts index 650a0a9b82391..864385ad0b1a3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './location_map'; -export * from '../availability_reporting/location_status_tags'; +export { ConnectionMode } from './connection_mode'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx new file mode 100644 index 0000000000000..04e8533a0d2af --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldNumber, EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { useAppContext } from '../../../../app_context'; +import { proxySettingsUrl } from '../../../../services/documentation'; +import { Props } from './connection_mode'; +import { CloudUrlHelp } from './cloud_url_help'; + +export const ProxyConnection: FunctionComponent = (props) => { + const { fields, fieldsErrors, areErrorsVisible, onFieldsChange } = props; + const { isCloudEnabled } = useAppContext(); + const { proxyAddress, serverName, proxySocketConnections, cloudUrl, cloudUrlEnabled } = fields; + const { + proxyAddress: proxyAddressError, + serverName: serverNameError, + cloudUrl: cloudUrlError, + } = fieldsErrors; + + return ( + <> + {cloudUrlEnabled ? ( + <> + + } + labelAppend={} + isInvalid={Boolean(areErrorsVisible && cloudUrlError)} + error={cloudUrlError} + fullWidth + helpText={ + + } + > + onFieldsChange({ cloudUrl: e.target.value })} + isInvalid={Boolean(areErrorsVisible && cloudUrlError)} + data-test-subj="remoteClusterFormCloudUrlInput" + fullWidth + /> + + + ) : ( + <> + + } + helpText={ + + } + isInvalid={Boolean(areErrorsVisible && proxyAddressError)} + error={proxyAddressError} + fullWidth + > + onFieldsChange({ proxyAddress: e.target.value })} + isInvalid={Boolean(areErrorsVisible && proxyAddressError)} + data-test-subj="remoteClusterFormProxyAddressInput" + fullWidth + /> + + + + ) : ( + + ) + } + helpText={ + + + + ), + }} + /> + } + fullWidth + > + onFieldsChange({ serverName: e.target.value })} + isInvalid={Boolean(areErrorsVisible && serverNameError)} + fullWidth + /> + + + )} + + } + helpText={ + + } + fullWidth + > + onFieldsChange({ proxySocketConnections: Number(e.target.value) })} + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx new file mode 100644 index 0000000000000..063aeb3490aef --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldNumber, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; + +import { transportPortUrl } from '../../../../services/documentation'; +import { validateSeed } from '../validators'; +import { Props } from './connection_mode'; + +export const SniffConnection: FunctionComponent = ({ + fields, + fieldsErrors, + areErrorsVisible, + onFieldsChange, +}) => { + const [localSeedErrors, setLocalSeedErrors] = useState([]); + const { seeds = [], nodeConnections } = fields; + const { seeds: seedsError } = fieldsErrors; + // Show errors if there is a general form error or local errors. + const areFormErrorsVisible = Boolean(areErrorsVisible && seedsError); + const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0; + const errors = + areFormErrorsVisible && seedsError ? localSeedErrors.concat(seedsError) : localSeedErrors; + const formattedSeeds: EuiComboBoxOptionOption[] = seeds.map((seed: string) => ({ label: seed })); + + const onCreateSeed = (newSeed?: string) => { + // If the user just hit enter without typing anything, treat it as a no-op. + if (!newSeed) { + return; + } + + const validationErrors = validateSeed(newSeed); + + if (validationErrors.length !== 0) { + setLocalSeedErrors(validationErrors); + // Return false to explicitly reject the user's input. + return false; + } + + const newSeeds = seeds.slice(0); + newSeeds.push(newSeed.toLowerCase()); + onFieldsChange({ seeds: newSeeds }); + }; + + const onSeedsInputChange = (seedInput?: string) => { + if (!seedInput) { + // If empty seedInput ("") don't do anything. This happens + // right after a seed is created. + return; + } + + // Allow typing to clear the errors, but not to add new ones. + const validationErrors = + !seedInput || validateSeed(seedInput).length === 0 ? [] : localSeedErrors; + + // EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the + // input is a duplicate. So we need to surface this error here instead. + const isDuplicate = seeds.includes(seedInput); + + if (isDuplicate) { + validationErrors.push( + + ); + } + + setLocalSeedErrors(validationErrors); + }; + return ( + <> + + } + helpText={ + + + + ), + }} + /> + } + isInvalid={showErrors} + error={errors} + fullWidth + > + + onFieldsChange({ seeds: options.map(({ label }) => label) }) + } + onSearchChange={onSeedsInputChange} + isInvalid={showErrors} + fullWidth + data-test-subj="remoteClusterFormSeedsInput" + /> + + + + } + helpText={ + + } + fullWidth + > + onFieldsChange({ nodeConnections: Number(e.target.value) })} + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.ts similarity index 100% rename from x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.js rename to x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.ts diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js deleted file mode 100644 index 325215d08af5f..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js +++ /dev/null @@ -1,962 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { merge } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiButtonEmpty, - EuiCallOut, - EuiComboBox, - EuiDescribedFormGroup, - EuiFieldNumber, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiLink, - EuiLoadingKibana, - EuiLoadingSpinner, - EuiOverlayMask, - EuiSpacer, - EuiSwitch, - EuiText, - EuiTitle, - EuiDelayRender, - EuiScreenReaderOnly, - htmlIdGenerator, -} from '@elastic/eui'; - -import { - skippingDisconnectedClustersUrl, - transportPortUrl, - proxySettingsUrl, -} from '../../../services/documentation'; - -import { RequestFlyout } from './request_flyout'; - -import { - validateName, - validateSeeds, - validateProxy, - validateSeed, - validateServerName, -} from './validators'; - -import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants'; - -import { AppContext } from '../../../app_context'; - -const defaultFields = { - name: '', - seeds: [], - skipUnavailable: false, - nodeConnections: 3, - proxyAddress: '', - proxySocketConnections: 18, - serverName: '', -}; - -const ERROR_TITLE_ID = 'removeClustersErrorTitle'; -const ERROR_LIST_ID = 'removeClustersErrorList'; - -export class RemoteClusterForm extends Component { - static propTypes = { - save: PropTypes.func.isRequired, - cancel: PropTypes.func, - isSaving: PropTypes.bool, - saveError: PropTypes.object, - fields: PropTypes.object, - disabledFields: PropTypes.object, - }; - - static defaultProps = { - fields: merge({}, defaultFields), - disabledFields: {}, - }; - - static contextType = AppContext; - - constructor(props, context) { - super(props, context); - - const { fields, disabledFields } = props; - const { isCloudEnabled } = context; - - // Connection mode should default to "proxy" in cloud - const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE; - const fieldsState = merge({}, { ...defaultFields, mode: defaultMode }, fields); - - this.generateId = htmlIdGenerator(); - this.state = { - localSeedErrors: [], - seedInput: '', - fields: fieldsState, - disabledFields, - fieldsErrors: this.getFieldsErrors(fieldsState), - areErrorsVisible: false, - isRequestVisible: false, - }; - } - - toggleRequest = () => { - this.setState(({ isRequestVisible }) => ({ - isRequestVisible: !isRequestVisible, - })); - }; - - getFieldsErrors(fields, seedInput = '') { - const { name, seeds, mode, proxyAddress, serverName } = fields; - const { isCloudEnabled } = this.context; - - return { - name: validateName(name), - seeds: mode === SNIFF_MODE ? validateSeeds(seeds, seedInput) : null, - proxyAddress: mode === PROXY_MODE ? validateProxy(proxyAddress) : null, - // server name is only required in cloud when proxy mode is enabled - serverName: isCloudEnabled && mode === PROXY_MODE ? validateServerName(serverName) : null, - }; - } - - onFieldsChange = (changedFields) => { - this.setState(({ fields: prevFields, seedInput }) => { - const newFields = { - ...prevFields, - ...changedFields, - }; - return { - fields: newFields, - fieldsErrors: this.getFieldsErrors(newFields, seedInput), - }; - }); - }; - - getAllFields() { - const { - fields: { - name, - mode, - seeds, - nodeConnections, - proxyAddress, - proxySocketConnections, - serverName, - skipUnavailable, - }, - } = this.state; - const { fields } = this.props; - - let modeSettings; - - if (mode === PROXY_MODE) { - modeSettings = { - proxyAddress, - proxySocketConnections, - serverName, - }; - } else { - modeSettings = { - seeds, - nodeConnections, - }; - } - - return { - name, - skipUnavailable, - mode, - hasDeprecatedProxySetting: fields.hasDeprecatedProxySetting, - ...modeSettings, - }; - } - - save = () => { - const { save } = this.props; - - if (this.hasErrors()) { - this.setState({ - areErrorsVisible: true, - }); - return; - } - - const cluster = this.getAllFields(); - save(cluster); - }; - - onCreateSeed = (newSeed) => { - // If the user just hit enter without typing anything, treat it as a no-op. - if (!newSeed) { - return; - } - - const localSeedErrors = validateSeed(newSeed); - - if (localSeedErrors.length !== 0) { - this.setState({ - localSeedErrors, - }); - - // Return false to explicitly reject the user's input. - return false; - } - - const { - fields: { seeds }, - } = this.state; - - const newSeeds = seeds.slice(0); - newSeeds.push(newSeed.toLowerCase()); - this.onFieldsChange({ seeds: newSeeds }); - }; - - onSeedsInputChange = (seedInput) => { - if (!seedInput) { - // If empty seedInput ("") don't do anything. This happens - // right after a seed is created. - return; - } - - this.setState(({ fields, localSeedErrors }) => { - const { seeds } = fields; - - // Allow typing to clear the errors, but not to add new ones. - const errors = !seedInput || validateSeed(seedInput).length === 0 ? [] : localSeedErrors; - - // EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the - // input is a duplicate. So we need to surface this error here instead. - const isDuplicate = seeds.includes(seedInput); - - if (isDuplicate) { - errors.push( - i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage', { - defaultMessage: `Duplicate seed nodes aren't allowed.`, - }) - ); - } - - return { - localSeedErrors: errors, - fieldsErrors: this.getFieldsErrors(fields, seedInput), - seedInput, - }; - }); - }; - - onSeedsChange = (seeds) => { - this.onFieldsChange({ seeds: seeds.map(({ label }) => label) }); - }; - - onSkipUnavailableChange = (e) => { - const skipUnavailable = e.target.checked; - this.onFieldsChange({ skipUnavailable }); - }; - - resetToDefault = (fieldName) => { - this.onFieldsChange({ - [fieldName]: defaultFields[fieldName], - }); - }; - - hasErrors = () => { - const { fieldsErrors, localSeedErrors } = this.state; - const errorValues = Object.values(fieldsErrors); - const hasErrors = errorValues.some((error) => error != null) || localSeedErrors.length; - return hasErrors; - }; - - renderSniffModeSettings() { - const { - areErrorsVisible, - fields: { seeds, nodeConnections }, - fieldsErrors: { seeds: errorsSeeds }, - localSeedErrors, - } = this.state; - - // Show errors if there is a general form error or local errors. - const areFormErrorsVisible = Boolean(areErrorsVisible && errorsSeeds); - const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0; - const errors = areFormErrorsVisible ? localSeedErrors.concat(errorsSeeds) : localSeedErrors; - - const formattedSeeds = seeds.map((seed) => ({ label: seed })); - - return ( - <> - - } - helpText={ - - - - ), - }} - /> - } - isInvalid={showErrors} - error={errors} - fullWidth - > - - - - - } - helpText={ - - } - fullWidth - > - - this.onFieldsChange({ nodeConnections: Number(e.target.value) || null }) - } - fullWidth - /> - - - ); - } - - renderProxyModeSettings() { - const { - areErrorsVisible, - fields: { proxyAddress, proxySocketConnections, serverName }, - fieldsErrors: { proxyAddress: errorProxyAddress, serverName: errorServerName }, - } = this.state; - - const { isCloudEnabled } = this.context; - - return ( - <> - - } - helpText={ - - } - isInvalid={Boolean(areErrorsVisible && errorProxyAddress)} - error={errorProxyAddress} - fullWidth - > - this.onFieldsChange({ proxyAddress: e.target.value })} - isInvalid={Boolean(areErrorsVisible && errorProxyAddress)} - data-test-subj="remoteClusterFormProxyAddressInput" - fullWidth - /> - - - - ) : ( - - ) - } - helpText={ - - - - ), - }} - /> - } - fullWidth - > - this.onFieldsChange({ serverName: e.target.value })} - isInvalid={Boolean(areErrorsVisible && errorServerName)} - fullWidth - /> - - - - } - helpText={ - - } - fullWidth - > - - this.onFieldsChange({ proxySocketConnections: Number(e.target.value) || null }) - } - fullWidth - /> - - - ); - } - - renderMode() { - const { - fields: { mode }, - } = this.state; - - const { isCloudEnabled } = this.context; - - return ( - -

- -

- - } - description={ - <> - - - - } - checked={mode === PROXY_MODE} - data-test-subj="remoteClusterFormConnectionModeToggle" - onChange={(e) => - this.onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) - } - /> - - {isCloudEnabled && mode === PROXY_MODE ? ( - <> - - - } - > - - - - ), - searchString: ( - - - - ), - }} - /> - - - ) : null} - - } - fullWidth - > - {mode === PROXY_MODE ? this.renderProxyModeSettings() : this.renderSniffModeSettings()} -
- ); - } - - renderSkipUnavailable() { - const { - fields: { skipUnavailable }, - } = this.state; - - return ( - -

- -

- - } - description={ - -

- - - - ), - learnMoreLink: ( - - - - ), - }} - /> -

-
- } - fullWidth - > - { - this.resetToDefault('skipUnavailable'); - }} - > - - - ) : null - } - > - - -
- ); - } - - renderActions() { - const { isSaving, cancel } = this.props; - const { areErrorsVisible, isRequestVisible } = this.state; - - if (isSaving) { - return ( - - - - - - - - - - - - ); - } - - let cancelButton; - - if (cancel) { - cancelButton = ( - - - - - - ); - } - - const isSaveDisabled = areErrorsVisible && this.hasErrors(); - - return ( - - - - - - - - - - {cancelButton} - - - - - - {isRequestVisible ? ( - - ) : ( - - )} - - - - ); - } - - renderSavingFeedback() { - if (this.props.isSaving) { - return ( - - - - ); - } - - return null; - } - - renderSaveErrorFeedback() { - const { saveError } = this.props; - - if (saveError) { - const { message, cause } = saveError; - - let errorBody; - - if (cause && Array.isArray(cause)) { - if (cause.length === 1) { - errorBody =

{cause[0]}

; - } else { - errorBody = ( -
    - {cause.map((causeValue) => ( -
  • {causeValue}
  • - ))} -
- ); - } - } - - return ( - - - {errorBody} - - - - - ); - } - - return null; - } - - renderErrors = () => { - const { - areErrorsVisible, - fieldsErrors: { name: errorClusterName, seeds: errorsSeeds, proxyAddress: errorProxyAddress }, - localSeedErrors, - } = this.state; - - const hasErrors = this.hasErrors(); - - if (!areErrorsVisible || !hasErrors) { - return null; - } - - const errorExplanations = []; - - if (errorClusterName) { - errorExplanations.push({ - key: 'nameExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', { - defaultMessage: 'The "Name" field is invalid.', - }), - error: errorClusterName, - }); - } - - if (errorsSeeds) { - errorExplanations.push({ - key: 'seedsExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', { - defaultMessage: 'The "Seed nodes" field is invalid.', - }), - error: errorsSeeds, - }); - } - - if (localSeedErrors && localSeedErrors.length) { - errorExplanations.push({ - key: 'localSeedExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage', { - defaultMessage: 'The "Seed nodes" field is invalid.', - }), - error: localSeedErrors.join(' '), - }); - } - - if (errorProxyAddress) { - errorExplanations.push({ - key: 'seedsExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', { - defaultMessage: 'The "Proxy address" field is invalid.', - }), - error: errorProxyAddress, - }); - } - - const messagesToBeRendered = errorExplanations.length && ( - -
- {errorExplanations.map(({ key, field, error }) => ( -
-
{field}
-
{error}
-
- ))} -
-
- ); - - return ( - - - - - - } - color="danger" - iconType="cross" - /> - {messagesToBeRendered} - - ); - }; - - render() { - const { - disabledFields: { name: disabledName }, - } = this.props; - - const { - isRequestVisible, - areErrorsVisible, - fields: { name }, - fieldsErrors: { name: errorClusterName }, - } = this.state; - - return ( - - {this.renderSaveErrorFeedback()} - - - -

- -

- - } - description={ - - } - fullWidth - > - - } - helpText={ - - } - error={errorClusterName} - isInvalid={Boolean(areErrorsVisible && errorClusterName)} - fullWidth - > - this.onFieldsChange({ name: e.target.value })} - fullWidth - disabled={disabledName} - data-test-subj="remoteClusterFormNameInput" - /> - -
- - {this.renderMode()} - - {this.renderSkipUnavailable()} -
- - {this.renderErrors()} - - - - {this.renderActions()} - - {this.renderSavingFeedback()} - - {isRequestVisible ? ( - this.setState({ isRequestVisible: false })} - /> - ) : null} -
- ); - } -} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js deleted file mode 100644 index 2ae16b8ca7cbf..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js +++ /dev/null @@ -1,53 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mountWithIntl, renderWithIntl } from '@kbn/test/jest'; -import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; -import { RemoteClusterForm } from './remote_cluster_form'; - -// Make sure we have deterministic aria IDs. -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: (prefix = 'staticGenerator') => (suffix = 'staticId') => `${prefix}_${suffix}`, -})); - -describe('RemoteClusterForm', () => { - test(`renders untouched state`, () => { - const component = renderWithIntl( {}} />); - expect(component).toMatchSnapshot(); - }); - - describe('proxy mode', () => { - test('renders correct connection settings when user enables proxy mode', () => { - const component = mountWithIntl( {}} />); - - findTestSubject(component, 'remoteClusterFormConnectionModeToggle').simulate('click'); - - expect(component).toMatchSnapshot(); - }); - }); - - describe('validation', () => { - test('renders invalid state and a global form error when the user tries to submit an invalid form', () => { - const component = mountWithIntl( {}} />); - - findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click'); - - const fieldsSnapshot = [ - 'remoteClusterFormNameFormRow', - 'remoteClusterFormSeedNodesFormRow', - 'remoteClusterFormSkipUnavailableFormRow', - 'remoteClusterFormGlobalError', - ].map((testSubject) => { - const mountedField = findTestSubject(component, testSubject); - return takeMountedSnapshot(mountedField); - }); - - expect(fieldsSnapshot).toMatchSnapshot(); - }); - }); -}); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx new file mode 100644 index 0000000000000..9f6eee757c755 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx @@ -0,0 +1,629 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component, Fragment } from 'react'; +import { merge } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLink, + EuiLoadingKibana, + EuiLoadingSpinner, + EuiOverlayMask, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, + EuiDelayRender, + EuiScreenReaderOnly, + htmlIdGenerator, + EuiSwitchEvent, +} from '@elastic/eui'; + +import { Cluster } from '../../../../../common/lib'; +import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants'; + +import { AppContext, Context } from '../../../app_context'; + +import { skippingDisconnectedClustersUrl } from '../../../services/documentation'; + +import { RequestFlyout } from './request_flyout'; +import { ConnectionMode } from './components'; +import { + ClusterErrors, + convertCloudUrlToProxyConnection, + convertProxyConnectionToCloudUrl, + validateCluster, +} from './validators'; +import { isCloudUrlEnabled } from './validators/validate_cloud_url'; + +const defaultClusterValues: Cluster = { + name: '', + seeds: [], + skipUnavailable: false, + nodeConnections: 3, + proxyAddress: '', + proxySocketConnections: 18, + serverName: '', +}; + +const ERROR_TITLE_ID = 'removeClustersErrorTitle'; +const ERROR_LIST_ID = 'removeClustersErrorList'; + +interface Props { + save: (cluster: Cluster) => void; + cancel?: () => void; + isSaving?: boolean; + saveError?: any; + cluster?: Cluster; +} + +export type FormFields = Cluster & { cloudUrl: string; cloudUrlEnabled: boolean }; + +interface State { + fields: FormFields; + fieldsErrors: ClusterErrors; + areErrorsVisible: boolean; + isRequestVisible: boolean; +} + +export class RemoteClusterForm extends Component { + static defaultProps = { + fields: merge({}, defaultClusterValues), + }; + + static contextType = AppContext; + private readonly generateId: (idSuffix?: string) => string; + + constructor(props: Props, context: Context) { + super(props, context); + + const { cluster } = props; + const { isCloudEnabled } = context; + + // Connection mode should default to "proxy" in cloud + const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE; + const fieldsState: FormFields = merge( + {}, + { + ...defaultClusterValues, + mode: defaultMode, + cloudUrl: convertProxyConnectionToCloudUrl(cluster), + cloudUrlEnabled: isCloudEnabled && isCloudUrlEnabled(cluster), + }, + cluster + ); + + this.generateId = htmlIdGenerator(); + this.state = { + fields: fieldsState, + fieldsErrors: validateCluster(fieldsState, isCloudEnabled), + areErrorsVisible: false, + isRequestVisible: false, + }; + } + + toggleRequest = () => { + this.setState(({ isRequestVisible }) => ({ + isRequestVisible: !isRequestVisible, + })); + }; + + onFieldsChange = (changedFields: Partial) => { + const { isCloudEnabled } = this.context; + + // when cloudUrl changes, fill proxy address and server name + const { cloudUrl } = changedFields; + if (cloudUrl) { + const { proxyAddress, serverName } = convertCloudUrlToProxyConnection(cloudUrl); + changedFields = { + ...changedFields, + proxyAddress, + serverName, + }; + } + + this.setState(({ fields: prevFields }) => { + const newFields = { + ...prevFields, + ...changedFields, + }; + return { + fields: newFields, + fieldsErrors: validateCluster(newFields, isCloudEnabled), + }; + }); + }; + + getCluster(): Cluster { + const { + fields: { + name, + mode, + seeds, + nodeConnections, + proxyAddress, + proxySocketConnections, + serverName, + skipUnavailable, + }, + } = this.state; + const { cluster } = this.props; + + let modeSettings; + + if (mode === PROXY_MODE) { + modeSettings = { + proxyAddress, + proxySocketConnections, + serverName, + }; + } else { + modeSettings = { + seeds, + nodeConnections, + }; + } + + return { + name, + skipUnavailable, + mode, + hasDeprecatedProxySetting: cluster?.hasDeprecatedProxySetting, + ...modeSettings, + }; + } + + save = () => { + const { save } = this.props; + + if (this.hasErrors()) { + this.setState({ + areErrorsVisible: true, + }); + return; + } + + const cluster = this.getCluster(); + save(cluster); + }; + + onSkipUnavailableChange = (e: EuiSwitchEvent) => { + const skipUnavailable = e.target.checked; + this.onFieldsChange({ skipUnavailable }); + }; + + resetToDefault = (fieldName: keyof Cluster) => { + this.onFieldsChange({ + [fieldName]: defaultClusterValues[fieldName], + }); + }; + + hasErrors = () => { + const { fieldsErrors } = this.state; + const errorValues = Object.values(fieldsErrors); + return errorValues.some((error) => error != null); + }; + + renderSkipUnavailable() { + const { + fields: { skipUnavailable }, + } = this.state; + + return ( + +

+ +

+ + } + description={ + +

+ + + + ), + learnMoreLink: ( + + + + ), + }} + /> +

+
+ } + fullWidth + > + { + this.resetToDefault('skipUnavailable'); + }} + > + + + ) : null + } + > + + +
+ ); + } + + renderActions() { + const { isSaving, cancel } = this.props; + const { areErrorsVisible, isRequestVisible } = this.state; + + if (isSaving) { + return ( + + + + + + + + + + + + ); + } + + let cancelButton; + + if (cancel) { + cancelButton = ( + + + + + + ); + } + + const isSaveDisabled = areErrorsVisible && this.hasErrors(); + + return ( + + + + + + + + + + {cancelButton} + + + + + + {isRequestVisible ? ( + + ) : ( + + )} + + + + ); + } + + renderSavingFeedback() { + if (this.props.isSaving) { + return ( + + + + ); + } + + return null; + } + + renderSaveErrorFeedback() { + const { saveError } = this.props; + + if (saveError) { + const { message, cause } = saveError; + + let errorBody; + + if (cause && Array.isArray(cause)) { + if (cause.length === 1) { + errorBody =

{cause[0]}

; + } else { + errorBody = ( +
    + {cause.map((causeValue) => ( +
  • {causeValue}
  • + ))} +
+ ); + } + } + + return ( + + + {errorBody} + + + + + ); + } + + return null; + } + + renderErrors = () => { + const { + areErrorsVisible, + fieldsErrors: { + name: errorClusterName, + seeds: errorsSeeds, + proxyAddress: errorProxyAddress, + serverName: errorServerName, + cloudUrl: errorCloudUrl, + }, + } = this.state; + + const hasErrors = this.hasErrors(); + + if (!areErrorsVisible || !hasErrors) { + return null; + } + + const errorExplanations = []; + + if (errorClusterName) { + errorExplanations.push({ + key: 'nameExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', { + defaultMessage: 'The "Name" field is invalid.', + }), + error: errorClusterName, + }); + } + + if (errorsSeeds) { + errorExplanations.push({ + key: 'seedsExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', { + defaultMessage: 'The "Seed nodes" field is invalid.', + }), + error: errorsSeeds, + }); + } + + if (errorProxyAddress) { + errorExplanations.push({ + key: 'proxyAddressExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', { + defaultMessage: 'The "Proxy address" field is invalid.', + }), + error: errorProxyAddress, + }); + } + + if (errorServerName) { + errorExplanations.push({ + key: 'serverNameExplanation', + field: i18n.translate( + 'xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage', + { + defaultMessage: 'The "Server name" field is invalid.', + } + ), + error: errorServerName, + }); + } + + if (errorCloudUrl) { + errorExplanations.push({ + key: 'cloudUrlExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage', { + defaultMessage: 'The "Elasticsearch endpoint URL" field is invalid.', + }), + error: errorCloudUrl, + }); + } + + const messagesToBeRendered = errorExplanations.length && ( + +
+ {errorExplanations.map(({ key, field, error }) => ( +
+
{field}
+
{error}
+
+ ))} +
+
+ ); + + return ( + + + + + + } + color="danger" + iconType="cross" + /> + {messagesToBeRendered} + + ); + }; + + render() { + const { isRequestVisible, areErrorsVisible, fields, fieldsErrors } = this.state; + const { name: errorClusterName } = fieldsErrors; + const { cluster } = this.props; + const isNew = !cluster; + return ( + + {this.renderSaveErrorFeedback()} + + + +

+ +

+ + } + description={ + + } + fullWidth + > + + } + helpText={ + + } + error={errorClusterName} + isInvalid={Boolean(areErrorsVisible && errorClusterName)} + fullWidth + > + this.onFieldsChange({ name: e.target.value })} + fullWidth + disabled={!isNew} + data-test-subj="remoteClusterFormNameInput" + /> + +
+ + + + {this.renderSkipUnavailable()} +
+ + {this.renderErrors()} + + + + {this.renderActions()} + + {this.renderSavingFeedback()} + + {isRequestVisible ? ( + this.setState({ isRequestVisible: false })} + /> + ) : null} +
+ ); + } +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx index 4e402b8b55a5b..2bcedc2bce458 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx @@ -24,13 +24,13 @@ import { Cluster, serializeCluster } from '../../../../../common/lib'; interface Props { close: () => void; - name: string; cluster: Cluster; } export class RequestFlyout extends PureComponent { render() { - const { name, close, cluster } = this.props; + const { close, cluster } = this.props; + const { name } = cluster; const endpoint = 'PUT _cluster/settings'; const payload = JSON.stringify(serializeCluster(cluster), null, 2); const request = `${endpoint}\n${payload}`; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts index 67a5d8f727f3e..6f3956a19f6a0 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts @@ -10,3 +10,10 @@ export { validateProxy } from './validate_proxy'; export { validateSeeds } from './validate_seeds'; export { validateSeed } from './validate_seed'; export { validateServerName } from './validate_server_name'; +export { validateCluster, ClusterErrors } from './validate_cluster'; +export { + isCloudUrlEnabled, + validateCloudUrl, + convertProxyConnectionToCloudUrl, + convertCloudUrlToProxyConnection, +} from './validate_cloud_url'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts new file mode 100644 index 0000000000000..599706ba85b02 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isCloudUrlEnabled, + validateCloudUrl, + convertCloudUrlToProxyConnection, + convertProxyConnectionToCloudUrl, + i18nTexts, +} from './validate_cloud_url'; + +describe('Cloud url', () => { + describe('validation', () => { + it('errors when the url is empty', () => { + const actual = validateCloudUrl(''); + expect(actual).toBe(i18nTexts.urlEmpty); + }); + + it('errors when the url is invalid', () => { + const actual = validateCloudUrl('invalid%url'); + expect(actual).toBe(i18nTexts.urlInvalid); + }); + }); + + describe('is cloud url', () => { + it('true for a new cluster', () => { + const actual = isCloudUrlEnabled(); + expect(actual).toBe(true); + }); + + it('true when proxy connection is empty', () => { + const actual = isCloudUrlEnabled({ name: 'test', proxyAddress: '', serverName: '' }); + expect(actual).toBe(true); + }); + + it('true when proxy address is the same as server name and default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-proxy', + }); + expect(actual).toBe(true); + }); + it('false when proxy address is the same as server name but not default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:1234', + serverName: 'some-proxy', + }); + expect(actual).toBe(false); + }); + it('true when proxy address is not the same as server name', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-server-name', + }); + expect(actual).toBe(false); + }); + }); + describe('conversion from cloud url', () => { + it('empty url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection(''); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + + it('url with protocol and port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com:1234'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with protocol and no port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with no protocol to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + it('invalid url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('invalid%url'); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + }); + + describe('conversion to cloud url', () => { + it('empty proxy address to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: '', + serverName: 'test', + }); + expect(actual).toEqual(''); + }); + + it('empty server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: '', + }); + expect(actual).toEqual(''); + }); + + it('different proxy address and server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: 'another-test', + }); + expect(actual).toEqual(''); + }); + + it('valid proxy connection to cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test-proxy:9400', + serverName: 'test-proxy', + }); + expect(actual).toEqual('test-proxy'); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx new file mode 100644 index 0000000000000..1f4862f0113e7 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Cluster } from '../../../../../../common/lib'; +import { isAddressValid } from './validate_address'; + +export const i18nTexts = { + urlEmpty: ( + + ), + urlInvalid: ( + + ), +}; + +const CLOUD_DEFAULT_PROXY_PORT = '9400'; +const EMPTY_PROXY_VALUES = { proxyAddress: '', serverName: '' }; +const PROTOCOL_REGEX = new RegExp(/^https?:\/\//); + +export const isCloudUrlEnabled = (cluster?: Cluster): boolean => { + // enable cloud url for new clusters + if (!cluster) { + return true; + } + const { proxyAddress, serverName } = cluster; + if (!proxyAddress && !serverName) { + return true; + } + const portParts = (proxyAddress ?? '').split(':'); + const proxyAddressWithoutPort = portParts[0]; + const port = portParts[1]; + return port === CLOUD_DEFAULT_PROXY_PORT && proxyAddressWithoutPort === serverName; +}; + +const formatUrl = (url: string) => { + url = (url ?? '').trim().toLowerCase(); + // delete http(s):// protocol string if any + url = url.replace(PROTOCOL_REGEX, ''); + return url; +}; + +export const convertProxyConnectionToCloudUrl = (cluster?: Cluster): string => { + if (!isCloudUrlEnabled(cluster)) { + return ''; + } + return cluster?.serverName ?? ''; +}; +export const convertCloudUrlToProxyConnection = ( + cloudUrl: string = '' +): { proxyAddress: string; serverName: string } => { + cloudUrl = formatUrl(cloudUrl); + if (!cloudUrl || !isAddressValid(cloudUrl)) { + return EMPTY_PROXY_VALUES; + } + const address = cloudUrl.split(':')[0]; + return { proxyAddress: `${address}:${CLOUD_DEFAULT_PROXY_PORT}`, serverName: address }; +}; + +export const validateCloudUrl = (cloudUrl: string): JSX.Element | null => { + if (!cloudUrl) { + return i18nTexts.urlEmpty; + } + cloudUrl = formatUrl(cloudUrl); + if (!isAddressValid(cloudUrl)) { + return i18nTexts.urlInvalid; + } + return null; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx new file mode 100644 index 0000000000000..e0fa434f21d5c --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateName } from './validate_name'; +import { PROXY_MODE, SNIFF_MODE } from '../../../../../../common/constants'; +import { validateSeeds } from './validate_seeds'; +import { validateProxy } from './validate_proxy'; +import { validateServerName } from './validate_server_name'; +import { validateCloudUrl } from './validate_cloud_url'; +import { FormFields } from '../remote_cluster_form'; + +type ClusterError = JSX.Element | null; + +export interface ClusterErrors { + name?: ClusterError; + seeds?: ClusterError; + proxyAddress?: ClusterError; + serverName?: ClusterError; + cloudUrl?: ClusterError; +} +export const validateCluster = (fields: FormFields, isCloudEnabled: boolean): ClusterErrors => { + const { name, seeds = [], mode, proxyAddress, serverName, cloudUrlEnabled, cloudUrl } = fields; + + return { + name: validateName(name), + seeds: mode === SNIFF_MODE ? validateSeeds(seeds) : null, + proxyAddress: !cloudUrlEnabled && mode === PROXY_MODE ? validateProxy(proxyAddress) : null, + // server name is only required in cloud when proxy mode is enabled + serverName: + !cloudUrlEnabled && isCloudEnabled && mode === PROXY_MODE + ? validateServerName(serverName) + : null, + cloudUrl: cloudUrlEnabled ? validateCloudUrl(cloudUrl) : null, + }; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts deleted file mode 100644 index a5b3656b36de5..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts +++ /dev/null @@ -1,43 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -import { isAddressValid, isPortValid } from './validate_address'; - -export function validateSeed(seed?: string): string[] { - const errors: string[] = []; - - if (!seed) { - return errors; - } - - const isValid = isAddressValid(seed); - - if (!isValid) { - errors.push( - i18n.translate( - 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage', - { - defaultMessage: - 'Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. ' + - 'Hosts can only consist of letters, numbers, and dashes.', - } - ) - ); - } - - if (!isPortValid(seed)) { - errors.push( - i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', { - defaultMessage: 'A port is required.', - }) - ); - } - - return errors; -} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx new file mode 100644 index 0000000000000..4863dff5ec337 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { isAddressValid, isPortValid } from './validate_address'; + +export function validateSeed(seed?: string): JSX.Element[] { + const errors: JSX.Element[] = []; + + if (!seed) { + return errors; + } + + const isValid = isAddressValid(seed); + + if (!isValid) { + errors.push( + + ); + } + + if (!isPortValid(seed)) { + errors.push( + + ); + } + + return errors; +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts b/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts new file mode 100644 index 0000000000000..ab0f579c1a415 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ComponentType } from 'react'; + +export declare const RemoteClusterEdit: ComponentType; +export declare const RemoteClusterAdd: ComponentType; +export declare const RemoteClusterList: ComponentType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js index 6ee6bd6d87d58..124d2d42afb78 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js @@ -73,7 +73,7 @@ export class RemoteClusterAdd extends PureComponent { description={ } /> diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js index c68dd7ab10aa7..18ee2e2b3875d 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js @@ -27,10 +27,6 @@ import { getRouter, redirect } from '../../services'; import { setBreadcrumbs } from '../../services/breadcrumb'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; -const disabledFields = { - name: true, -}; - export class RemoteClusterEdit extends Component { static propTypes = { isLoading: PropTypes.bool, @@ -202,8 +198,7 @@ export class RemoteClusterEdit extends Component { ) : null} Store; diff --git a/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png b/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png new file mode 100644 index 0000000000000..f6c9302ef76ea Binary files /dev/null and b/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png differ diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index 107d4e127d1b5..540a8b40a6208 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -60,13 +60,14 @@ export class RemoteClustersUIPlugin initNotification(toasts, fatalErrors); initHttp(http); - const isCloudEnabled = Boolean(cloud?.isCloudEnabled); + const isCloudEnabled: boolean = Boolean(cloud?.isCloudEnabled); + const cloudBaseUrl: string = cloud?.baseUrl ?? ''; const { renderApp } = await import('./application'); const unmountAppCallback = await renderApp( element, i18nContext, - { isCloudEnabled }, + { isCloudEnabled, cloudBaseUrl }, history ); diff --git a/x-pack/plugins/remote_clusters/tsconfig.json b/x-pack/plugins/remote_clusters/tsconfig.json index 0bee6300cf0b2..9dc7926bd62ea 100644 --- a/x-pack/plugins/remote_clusters/tsconfig.json +++ b/x-pack/plugins/remote_clusters/tsconfig.json @@ -8,10 +8,12 @@ "declarationMap": true }, "include": [ + "__jest__/**/*", "common/**/*", "fixtures/**/*", "public/**/*", "server/**/*", + "../../../typings/**/*", ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 85c5379a63b7f..01959ed08036d 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -79,7 +79,7 @@ export class CsvGenerator { searchSource: ISearchSource, scrollSettings: CsvExportSettings['scroll'] ) { - const searchBody = await searchSource.getSearchRequestBody(); + const searchBody = searchSource.getSearchRequestBody(); this.logger.debug(`executing search request`); const searchParams = { params: { diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md new file mode 100644 index 0000000000000..17fe2b20f74fa --- /dev/null +++ b/x-pack/plugins/rule_registry/README.md @@ -0,0 +1,68 @@ +The rule registry plugin aims to make it easy for rule type producers to have their rules produce the data that they need to build rich experiences on top of a unified experience, without the risk of mapping conflicts. + +A rule registry creates a template, an ILM policy, and an alias. The template mappings can be configured. It also injects a client scoped to these indices. + +It also supports inheritance, which means that producers can create a registry specific to their solution or rule type, and specify additional mappings to be used. + +The rule registry plugin creates a root rule registry, with the mappings defined needed to create a unified experience. Rule type producers can use the plugin to access the root rule registry, and create their own registry that branches off of the root rule registry. The rule registry client sees data from its own registry, and all registries that branches off of it. It does not see data from its parents. + +Creating a rule registry + +To create a rule registry, producers should add the `ruleRegistry` plugin to their dependencies. They can then use the `ruleRegistry.create` method to create a child registry, with the additional mappings that should be used by specifying `fieldMap`: + +```ts +const observabilityRegistry = plugins.ruleRegistry.create({ + name: 'observability', + fieldMap: { + ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), + }, +}) +``` + +`fieldMap` is a key-value map of field names and mapping options: + +```ts +{ + '@timestamp': { + type: 'date', + array: false, + required: true, + } +} +``` + +ECS mappings are generated via a script in the rule registry plugin directory. These mappings are available in x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts. + +To pick many fields, you can use `pickWithPatterns`, which supports wildcards with full type support. + +If a registry is created, it will initialise as soon as the core services needed become available. It will create a (versioned) template, alias, and ILM policy, but only if these do not exist yet. + +### Rule registry client + +The rule registry client can either be injected in the executor, or created in the scope of a request. It exposes a `search` method and a `bulkIndex` method. When `search` is called, it first gets all the rules the current user has access to, and adds these ids to the search request that it executes. This means that the user can only see data from rules they have access to. + +Both `search` and `bulkIndex` are fully typed, in the sense that they reflect the mappings defined for the registry. + +### Schema + +The following fields are available in the root rule registry: + +- `@timestamp`: the ISO timestamp of the alert event. For the lifecycle rule type helper, it is always the value of `startedAt` that is injected by the Kibana alerting framework. +- `event.kind`: signal (for the changeable alert document), state (for the state changes of the alert, e.g. when it opens, recovers, or changes in severity), or metric (individual evaluations that might be related to an alert). +- `event.action`: the reason for the event. This might be `open`, `close`, `active`, or `evaluate`. +- `tags`: tags attached to the alert. Right now they are copied over from the rule. +- `rule.id`: the identifier of the rule type, e.g. `apm.transaction_duration` +- `rule.uuid`: the saved objects id of the rule. +- `rule.name`: the name of the rule (as specified by the user). +- `rule.category`: the name of the rule type (as defined by the rule type producer) +- `kibana.rac.producer`: the producer of the rule type. Usually a Kibana plugin. e.g., `APM`. +- `kibana.rac.alert.id`: the id of the alert, that is unique within the context of the rule execution it was created in. E.g., for a rule that monitors latency for all services in all environments, this might be `opbeans-java:production`. +- `kibana.rac.alert.uuid`: the unique identifier for the alert during its lifespan. If an alert recovers (or closes), this identifier is re-generated when it is opened again. +- `kibana.rac.alert.status`: the status of the alert. Can be `open` or `closed`. +- `kibana.rac.alert.start`: the ISO timestamp of the time at which the alert started. +- `kibana.rac.alert.end`: the ISO timestamp of the time at which the alert recovered. +- `kibana.rac.alert.duration.us`: the duration of the alert, in microseconds. This is always the difference between either the current time, or the time when the alert recovered. +- `kibana.rac.alert.severity.level`: the severity of the alert, as a keyword (e.g. critical). +- `kibana.rac.alert.severity.value`: the severity of the alert, as a numerical value, which allows sorting. + +This list is not final - just a start. Field names might change or moved to a scoped registry. If we implement log and sequence based rule types the list of fields will grow. If a rule type needs additional fields, the recommendation would be to have the field in its own registry first (or in its producer’s registry), and if usage is more broadly adopted, it can be moved to the root registry. diff --git a/x-pack/plugins/rule_registry/common/index.ts b/x-pack/plugins/rule_registry/common/index.ts new file mode 100644 index 0000000000000..6cc0ccaa93a6d --- /dev/null +++ b/x-pack/plugins/rule_registry/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './types'; diff --git a/x-pack/plugins/rule_registry/common/types.ts b/x-pack/plugins/rule_registry/common/types.ts new file mode 100644 index 0000000000000..d0d15d86a2248 --- /dev/null +++ b/x-pack/plugins/rule_registry/common/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum AlertSeverityLevel { + warning = 'warning', + critical = 'critical', +} + +const alertSeverityLevelValues = { + [AlertSeverityLevel.warning]: 70, + [AlertSeverityLevel.critical]: 90, +}; + +export function getAlertSeverityLevelValue(level: AlertSeverityLevel) { + return alertSeverityLevelValues[level]; +} diff --git a/x-pack/plugins/rule_registry/jest.config.js b/x-pack/plugins/rule_registry/jest.config.js new file mode 100644 index 0000000000000..df8ac522e4b5d --- /dev/null +++ b/x-pack/plugins/rule_registry/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/rule_registry'], +}; diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json new file mode 100644 index 0000000000000..dea6ef560cc2d --- /dev/null +++ b/x-pack/plugins/rule_registry/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "ruleRegistry", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": [ + "xpack", + "ruleRegistry" + ], + "requiredPlugins": [ + "alerting" + ], + "server": true +} diff --git a/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js new file mode 100644 index 0000000000000..6e3a8f7cbe663 --- /dev/null +++ b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +const path = require('path'); +const fs = require('fs'); +const util = require('util'); +const yaml = require('js-yaml'); +const { exec: execCb } = require('child_process'); +const { mapValues } = require('lodash'); + +const exists = util.promisify(fs.exists); +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); +const mkdir = util.promisify(fs.mkdir); +const rmdir = util.promisify(fs.rmdir); +const exec = util.promisify(execCb); + +const ecsDir = path.resolve(__dirname, '../../../../../../ecs'); +const ecsTemplateFilename = path.join(ecsDir, 'generated/elasticsearch/7/template.json'); +const flatYamlFilename = path.join(ecsDir, 'generated/ecs/ecs_flat.yml'); + +const outputDir = path.join(__dirname, '../../server/generated'); + +const outputFieldMapFilename = path.join(outputDir, 'ecs_field_map.ts'); +const outputMappingFilename = path.join(outputDir, 'ecs_mappings.json'); + +async function generate() { + const allExists = await Promise.all([exists(ecsDir), exists(ecsTemplateFilename)]); + + if (!allExists.every(Boolean)) { + throw new Error( + `Directory not found: ${ecsDir} - did you checkout elastic/ecs as a peer of this repo?` + ); + } + + const [template, flatYaml] = await Promise.all([ + readFile(ecsTemplateFilename, { encoding: 'utf-8' }).then((str) => JSON.parse(str)), + (async () => yaml.safeLoad(await readFile(flatYamlFilename)))(), + ]); + + const mappings = { + properties: template.mappings.properties, + }; + + const fields = mapValues(flatYaml, (description) => { + return { + type: description.type, + array: description.normalize.includes('array'), + required: !!description.required, + }; + }); + + const hasOutputDir = await exists(outputDir); + + if (hasOutputDir) { + await rmdir(outputDir, { recursive: true }); + } + + await mkdir(outputDir); + + await Promise.all([ + writeFile( + outputFieldMapFilename, + ` + export const ecsFieldMap = ${JSON.stringify(fields, null, 2)} as const + `, + { encoding: 'utf-8' } + ).then(() => { + return exec(`node scripts/eslint --fix ${outputFieldMapFilename}`); + }), + writeFile(outputMappingFilename, JSON.stringify(mappings, null, 2)), + ]); +} + +generate().catch((err) => { + console.log(err); + process.exit(1); +}); diff --git a/x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts b/x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts new file mode 100644 index 0000000000000..cd8865a3f57c2 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts @@ -0,0 +1,3374 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ecsFieldMap = { + '@timestamp': { + type: 'date', + array: false, + required: true, + }, + 'agent.build.original': { + type: 'keyword', + array: false, + required: false, + }, + 'agent.ephemeral_id': { + type: 'keyword', + array: false, + required: false, + }, + 'agent.id': { + type: 'keyword', + array: false, + required: false, + }, + 'agent.name': { + type: 'keyword', + array: false, + required: false, + }, + 'agent.type': { + type: 'keyword', + array: false, + required: false, + }, + 'agent.version': { + type: 'keyword', + array: false, + required: false, + }, + 'client.address': { + type: 'keyword', + array: false, + required: false, + }, + 'client.as.number': { + type: 'long', + array: false, + required: false, + }, + 'client.as.organization.name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.bytes': { + type: 'long', + array: false, + required: false, + }, + 'client.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'client.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'client.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.ip': { + type: 'ip', + array: false, + required: false, + }, + 'client.mac': { + type: 'keyword', + array: false, + required: false, + }, + 'client.nat.ip': { + type: 'ip', + array: false, + required: false, + }, + 'client.nat.port': { + type: 'long', + array: false, + required: false, + }, + 'client.packets': { + type: 'long', + array: false, + required: false, + }, + 'client.port': { + type: 'long', + array: false, + required: false, + }, + 'client.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'client.user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'cloud.account.id': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.account.name': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.availability_zone': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.instance.id': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.instance.name': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.machine.type': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.project.id': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.project.name': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.provider': { + type: 'keyword', + array: false, + required: false, + }, + 'cloud.region': { + type: 'keyword', + array: false, + required: false, + }, + 'container.id': { + type: 'keyword', + array: false, + required: false, + }, + 'container.image.name': { + type: 'keyword', + array: false, + required: false, + }, + 'container.image.tag': { + type: 'keyword', + array: true, + required: false, + }, + 'container.labels': { + type: 'object', + array: false, + required: false, + }, + 'container.name': { + type: 'keyword', + array: false, + required: false, + }, + 'container.runtime': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.address': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.as.number': { + type: 'long', + array: false, + required: false, + }, + 'destination.as.organization.name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.bytes': { + type: 'long', + array: false, + required: false, + }, + 'destination.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'destination.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.ip': { + type: 'ip', + array: false, + required: false, + }, + 'destination.mac': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.nat.ip': { + type: 'ip', + array: false, + required: false, + }, + 'destination.nat.port': { + type: 'long', + array: false, + required: false, + }, + 'destination.packets': { + type: 'long', + array: false, + required: false, + }, + 'destination.port': { + type: 'long', + array: false, + required: false, + }, + 'destination.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'dll.code_signature.exists': { + type: 'boolean', + array: false, + required: false, + }, + 'dll.code_signature.status': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.code_signature.subject_name': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.code_signature.trusted': { + type: 'boolean', + array: false, + required: false, + }, + 'dll.code_signature.valid': { + type: 'boolean', + array: false, + required: false, + }, + 'dll.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.hash.sha512': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.name': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.path': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.company': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.description': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.file_version': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.imphash': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.original_file_name': { + type: 'keyword', + array: false, + required: false, + }, + 'dll.pe.product': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.answers': { + type: 'object', + array: true, + required: false, + }, + 'dns.answers.class': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.answers.data': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.answers.name': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.answers.ttl': { + type: 'long', + array: false, + required: false, + }, + 'dns.answers.type': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.header_flags': { + type: 'keyword', + array: true, + required: false, + }, + 'dns.id': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.op_code': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.class': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.name': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.question.type': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.resolved_ip': { + type: 'ip', + array: true, + required: false, + }, + 'dns.response_code': { + type: 'keyword', + array: false, + required: false, + }, + 'dns.type': { + type: 'keyword', + array: false, + required: false, + }, + 'ecs.version': { + type: 'keyword', + array: false, + required: true, + }, + 'error.code': { + type: 'keyword', + array: false, + required: false, + }, + 'error.id': { + type: 'keyword', + array: false, + required: false, + }, + 'error.message': { + type: 'text', + array: false, + required: false, + }, + 'error.stack_trace': { + type: 'keyword', + array: false, + required: false, + }, + 'error.type': { + type: 'keyword', + array: false, + required: false, + }, + 'event.action': { + type: 'keyword', + array: false, + required: false, + }, + 'event.category': { + type: 'keyword', + array: true, + required: false, + }, + 'event.code': { + type: 'keyword', + array: false, + required: false, + }, + 'event.created': { + type: 'date', + array: false, + required: false, + }, + 'event.dataset': { + type: 'keyword', + array: false, + required: false, + }, + 'event.duration': { + type: 'long', + array: false, + required: false, + }, + 'event.end': { + type: 'date', + array: false, + required: false, + }, + 'event.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'event.id': { + type: 'keyword', + array: false, + required: false, + }, + 'event.ingested': { + type: 'date', + array: false, + required: false, + }, + 'event.kind': { + type: 'keyword', + array: false, + required: false, + }, + 'event.module': { + type: 'keyword', + array: false, + required: false, + }, + 'event.original': { + type: 'keyword', + array: false, + required: false, + }, + 'event.outcome': { + type: 'keyword', + array: false, + required: false, + }, + 'event.provider': { + type: 'keyword', + array: false, + required: false, + }, + 'event.reason': { + type: 'keyword', + array: false, + required: false, + }, + 'event.reference': { + type: 'keyword', + array: false, + required: false, + }, + 'event.risk_score': { + type: 'float', + array: false, + required: false, + }, + 'event.risk_score_norm': { + type: 'float', + array: false, + required: false, + }, + 'event.sequence': { + type: 'long', + array: false, + required: false, + }, + 'event.severity': { + type: 'long', + array: false, + required: false, + }, + 'event.start': { + type: 'date', + array: false, + required: false, + }, + 'event.timezone': { + type: 'keyword', + array: false, + required: false, + }, + 'event.type': { + type: 'keyword', + array: true, + required: false, + }, + 'event.url': { + type: 'keyword', + array: false, + required: false, + }, + 'file.accessed': { + type: 'date', + array: false, + required: false, + }, + 'file.attributes': { + type: 'keyword', + array: true, + required: false, + }, + 'file.code_signature.exists': { + type: 'boolean', + array: false, + required: false, + }, + 'file.code_signature.status': { + type: 'keyword', + array: false, + required: false, + }, + 'file.code_signature.subject_name': { + type: 'keyword', + array: false, + required: false, + }, + 'file.code_signature.trusted': { + type: 'boolean', + array: false, + required: false, + }, + 'file.code_signature.valid': { + type: 'boolean', + array: false, + required: false, + }, + 'file.created': { + type: 'date', + array: false, + required: false, + }, + 'file.ctime': { + type: 'date', + array: false, + required: false, + }, + 'file.device': { + type: 'keyword', + array: false, + required: false, + }, + 'file.directory': { + type: 'keyword', + array: false, + required: false, + }, + 'file.drive_letter': { + type: 'keyword', + array: false, + required: false, + }, + 'file.extension': { + type: 'keyword', + array: false, + required: false, + }, + 'file.gid': { + type: 'keyword', + array: false, + required: false, + }, + 'file.group': { + type: 'keyword', + array: false, + required: false, + }, + 'file.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'file.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'file.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'file.hash.sha512': { + type: 'keyword', + array: false, + required: false, + }, + 'file.inode': { + type: 'keyword', + array: false, + required: false, + }, + 'file.mime_type': { + type: 'keyword', + array: false, + required: false, + }, + 'file.mode': { + type: 'keyword', + array: false, + required: false, + }, + 'file.mtime': { + type: 'date', + array: false, + required: false, + }, + 'file.name': { + type: 'keyword', + array: false, + required: false, + }, + 'file.owner': { + type: 'keyword', + array: false, + required: false, + }, + 'file.path': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.company': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.description': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.file_version': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.imphash': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.original_file_name': { + type: 'keyword', + array: false, + required: false, + }, + 'file.pe.product': { + type: 'keyword', + array: false, + required: false, + }, + 'file.size': { + type: 'long', + array: false, + required: false, + }, + 'file.target_path': { + type: 'keyword', + array: false, + required: false, + }, + 'file.type': { + type: 'keyword', + array: false, + required: false, + }, + 'file.uid': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.alternative_names': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.country': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.issuer.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.issuer.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.not_after': { + type: 'date', + array: false, + required: false, + }, + 'file.x509.not_before': { + type: 'date', + array: false, + required: false, + }, + 'file.x509.public_key_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.public_key_curve': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.public_key_exponent': { + type: 'long', + array: false, + required: false, + }, + 'file.x509.public_key_size': { + type: 'long', + array: false, + required: false, + }, + 'file.x509.serial_number': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.signature_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.subject.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.subject.country': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.subject.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'file.x509.subject.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.subject.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.subject.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.subject.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'file.x509.version_number': { + type: 'keyword', + array: false, + required: false, + }, + 'group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'host.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'host.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'host.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.hostname': { + type: 'keyword', + array: false, + required: false, + }, + 'host.id': { + type: 'keyword', + array: false, + required: false, + }, + 'host.ip': { + type: 'ip', + array: true, + required: false, + }, + 'host.mac': { + type: 'keyword', + array: true, + required: false, + }, + 'host.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.family': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.full': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.kernel': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.platform': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.type': { + type: 'keyword', + array: false, + required: false, + }, + 'host.os.version': { + type: 'keyword', + array: false, + required: false, + }, + 'host.type': { + type: 'keyword', + array: false, + required: false, + }, + 'host.uptime': { + type: 'long', + array: false, + required: false, + }, + 'host.user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'host.user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'http.request.body.bytes': { + type: 'long', + array: false, + required: false, + }, + 'http.request.body.content': { + type: 'keyword', + array: false, + required: false, + }, + 'http.request.bytes': { + type: 'long', + array: false, + required: false, + }, + 'http.request.method': { + type: 'keyword', + array: false, + required: false, + }, + 'http.request.mime_type': { + type: 'keyword', + array: false, + required: false, + }, + 'http.request.referrer': { + type: 'keyword', + array: false, + required: false, + }, + 'http.response.body.bytes': { + type: 'long', + array: false, + required: false, + }, + 'http.response.body.content': { + type: 'keyword', + array: false, + required: false, + }, + 'http.response.bytes': { + type: 'long', + array: false, + required: false, + }, + 'http.response.mime_type': { + type: 'keyword', + array: false, + required: false, + }, + 'http.response.status_code': { + type: 'long', + array: false, + required: false, + }, + 'http.version': { + type: 'keyword', + array: false, + required: false, + }, + labels: { + type: 'object', + array: false, + required: false, + }, + 'log.file.path': { + type: 'keyword', + array: false, + required: false, + }, + 'log.level': { + type: 'keyword', + array: false, + required: false, + }, + 'log.logger': { + type: 'keyword', + array: false, + required: false, + }, + 'log.origin.file.line': { + type: 'integer', + array: false, + required: false, + }, + 'log.origin.file.name': { + type: 'keyword', + array: false, + required: false, + }, + 'log.origin.function': { + type: 'keyword', + array: false, + required: false, + }, + 'log.original': { + type: 'keyword', + array: false, + required: false, + }, + 'log.syslog': { + type: 'object', + array: false, + required: false, + }, + 'log.syslog.facility.code': { + type: 'long', + array: false, + required: false, + }, + 'log.syslog.facility.name': { + type: 'keyword', + array: false, + required: false, + }, + 'log.syslog.priority': { + type: 'long', + array: false, + required: false, + }, + 'log.syslog.severity.code': { + type: 'long', + array: false, + required: false, + }, + 'log.syslog.severity.name': { + type: 'keyword', + array: false, + required: false, + }, + message: { + type: 'text', + array: false, + required: false, + }, + 'network.application': { + type: 'keyword', + array: false, + required: false, + }, + 'network.bytes': { + type: 'long', + array: false, + required: false, + }, + 'network.community_id': { + type: 'keyword', + array: false, + required: false, + }, + 'network.direction': { + type: 'keyword', + array: false, + required: false, + }, + 'network.forwarded_ip': { + type: 'ip', + array: false, + required: false, + }, + 'network.iana_number': { + type: 'keyword', + array: false, + required: false, + }, + 'network.inner': { + type: 'object', + array: false, + required: false, + }, + 'network.inner.vlan.id': { + type: 'keyword', + array: false, + required: false, + }, + 'network.inner.vlan.name': { + type: 'keyword', + array: false, + required: false, + }, + 'network.name': { + type: 'keyword', + array: false, + required: false, + }, + 'network.packets': { + type: 'long', + array: false, + required: false, + }, + 'network.protocol': { + type: 'keyword', + array: false, + required: false, + }, + 'network.transport': { + type: 'keyword', + array: false, + required: false, + }, + 'network.type': { + type: 'keyword', + array: false, + required: false, + }, + 'network.vlan.id': { + type: 'keyword', + array: false, + required: false, + }, + 'network.vlan.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress': { + type: 'object', + array: false, + required: false, + }, + 'observer.egress.interface.alias': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress.interface.id': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress.interface.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress.vlan.id': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress.vlan.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.egress.zone': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'observer.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.hostname': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress': { + type: 'object', + array: false, + required: false, + }, + 'observer.ingress.interface.alias': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress.interface.id': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress.interface.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress.vlan.id': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress.vlan.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ingress.zone': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.ip': { + type: 'ip', + array: true, + required: false, + }, + 'observer.mac': { + type: 'keyword', + array: true, + required: false, + }, + 'observer.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.family': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.full': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.kernel': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.name': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.platform': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.type': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.os.version': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.product': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.serial_number': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.type': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.vendor': { + type: 'keyword', + array: false, + required: false, + }, + 'observer.version': { + type: 'keyword', + array: false, + required: false, + }, + 'organization.id': { + type: 'keyword', + array: false, + required: false, + }, + 'organization.name': { + type: 'keyword', + array: false, + required: false, + }, + 'package.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'package.build_version': { + type: 'keyword', + array: false, + required: false, + }, + 'package.checksum': { + type: 'keyword', + array: false, + required: false, + }, + 'package.description': { + type: 'keyword', + array: false, + required: false, + }, + 'package.install_scope': { + type: 'keyword', + array: false, + required: false, + }, + 'package.installed': { + type: 'date', + array: false, + required: false, + }, + 'package.license': { + type: 'keyword', + array: false, + required: false, + }, + 'package.name': { + type: 'keyword', + array: false, + required: false, + }, + 'package.path': { + type: 'keyword', + array: false, + required: false, + }, + 'package.reference': { + type: 'keyword', + array: false, + required: false, + }, + 'package.size': { + type: 'long', + array: false, + required: false, + }, + 'package.type': { + type: 'keyword', + array: false, + required: false, + }, + 'package.version': { + type: 'keyword', + array: false, + required: false, + }, + 'process.args': { + type: 'keyword', + array: true, + required: false, + }, + 'process.args_count': { + type: 'long', + array: false, + required: false, + }, + 'process.code_signature.exists': { + type: 'boolean', + array: false, + required: false, + }, + 'process.code_signature.status': { + type: 'keyword', + array: false, + required: false, + }, + 'process.code_signature.subject_name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.code_signature.trusted': { + type: 'boolean', + array: false, + required: false, + }, + 'process.code_signature.valid': { + type: 'boolean', + array: false, + required: false, + }, + 'process.command_line': { + type: 'keyword', + array: false, + required: false, + }, + 'process.entity_id': { + type: 'keyword', + array: false, + required: false, + }, + 'process.executable': { + type: 'keyword', + array: false, + required: false, + }, + 'process.exit_code': { + type: 'long', + array: false, + required: false, + }, + 'process.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'process.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'process.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'process.hash.sha512': { + type: 'keyword', + array: false, + required: false, + }, + 'process.name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.args': { + type: 'keyword', + array: true, + required: false, + }, + 'process.parent.args_count': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.code_signature.exists': { + type: 'boolean', + array: false, + required: false, + }, + 'process.parent.code_signature.status': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.code_signature.subject_name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.code_signature.trusted': { + type: 'boolean', + array: false, + required: false, + }, + 'process.parent.code_signature.valid': { + type: 'boolean', + array: false, + required: false, + }, + 'process.parent.command_line': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.entity_id': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.executable': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.exit_code': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.hash.sha512': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.company': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.description': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.file_version': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.imphash': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.original_file_name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pe.product': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.pgid': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.pid': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.ppid': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.start': { + type: 'date', + array: false, + required: false, + }, + 'process.parent.thread.id': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.thread.name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.title': { + type: 'keyword', + array: false, + required: false, + }, + 'process.parent.uptime': { + type: 'long', + array: false, + required: false, + }, + 'process.parent.working_directory': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.architecture': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.company': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.description': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.file_version': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.imphash': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.original_file_name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pe.product': { + type: 'keyword', + array: false, + required: false, + }, + 'process.pgid': { + type: 'long', + array: false, + required: false, + }, + 'process.pid': { + type: 'long', + array: false, + required: false, + }, + 'process.ppid': { + type: 'long', + array: false, + required: false, + }, + 'process.start': { + type: 'date', + array: false, + required: false, + }, + 'process.thread.id': { + type: 'long', + array: false, + required: false, + }, + 'process.thread.name': { + type: 'keyword', + array: false, + required: false, + }, + 'process.title': { + type: 'keyword', + array: false, + required: false, + }, + 'process.uptime': { + type: 'long', + array: false, + required: false, + }, + 'process.working_directory': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.data.bytes': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.data.strings': { + type: 'keyword', + array: true, + required: false, + }, + 'registry.data.type': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.hive': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.key': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.path': { + type: 'keyword', + array: false, + required: false, + }, + 'registry.value': { + type: 'keyword', + array: false, + required: false, + }, + 'related.hash': { + type: 'keyword', + array: true, + required: false, + }, + 'related.hosts': { + type: 'keyword', + array: true, + required: false, + }, + 'related.ip': { + type: 'ip', + array: true, + required: false, + }, + 'related.user': { + type: 'keyword', + array: true, + required: false, + }, + 'rule.author': { + type: 'keyword', + array: true, + required: false, + }, + 'rule.category': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.description': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.id': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.license': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.name': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.reference': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.ruleset': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.uuid': { + type: 'keyword', + array: false, + required: false, + }, + 'rule.version': { + type: 'keyword', + array: false, + required: false, + }, + 'server.address': { + type: 'keyword', + array: false, + required: false, + }, + 'server.as.number': { + type: 'long', + array: false, + required: false, + }, + 'server.as.organization.name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.bytes': { + type: 'long', + array: false, + required: false, + }, + 'server.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'server.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'server.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.ip': { + type: 'ip', + array: false, + required: false, + }, + 'server.mac': { + type: 'keyword', + array: false, + required: false, + }, + 'server.nat.ip': { + type: 'ip', + array: false, + required: false, + }, + 'server.nat.port': { + type: 'long', + array: false, + required: false, + }, + 'server.packets': { + type: 'long', + array: false, + required: false, + }, + 'server.port': { + type: 'long', + array: false, + required: false, + }, + 'server.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'server.user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'service.ephemeral_id': { + type: 'keyword', + array: false, + required: false, + }, + 'service.id': { + type: 'keyword', + array: false, + required: false, + }, + 'service.name': { + type: 'keyword', + array: false, + required: false, + }, + 'service.node.name': { + type: 'keyword', + array: false, + required: false, + }, + 'service.state': { + type: 'keyword', + array: false, + required: false, + }, + 'service.type': { + type: 'keyword', + array: false, + required: false, + }, + 'service.version': { + type: 'keyword', + array: false, + required: false, + }, + 'source.address': { + type: 'keyword', + array: false, + required: false, + }, + 'source.as.number': { + type: 'long', + array: false, + required: false, + }, + 'source.as.organization.name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.bytes': { + type: 'long', + array: false, + required: false, + }, + 'source.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.continent_name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.country_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.country_name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.location': { + type: 'geo_point', + array: false, + required: false, + }, + 'source.geo.name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.region_iso_code': { + type: 'keyword', + array: false, + required: false, + }, + 'source.geo.region_name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.ip': { + type: 'ip', + array: false, + required: false, + }, + 'source.mac': { + type: 'keyword', + array: false, + required: false, + }, + 'source.nat.ip': { + type: 'ip', + array: false, + required: false, + }, + 'source.nat.port': { + type: 'long', + array: false, + required: false, + }, + 'source.packets': { + type: 'long', + array: false, + required: false, + }, + 'source.port': { + type: 'long', + array: false, + required: false, + }, + 'source.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'source.user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'span.id': { + type: 'keyword', + array: false, + required: false, + }, + tags: { + type: 'keyword', + array: true, + required: false, + }, + 'threat.framework': { + type: 'keyword', + array: false, + required: false, + }, + 'threat.tactic.id': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.tactic.name': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.tactic.reference': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.id': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.name': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.reference': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.subtechnique.id': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.subtechnique.name': { + type: 'keyword', + array: true, + required: false, + }, + 'threat.technique.subtechnique.reference': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.cipher': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.certificate': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.certificate_chain': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.issuer': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.ja3': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.not_after': { + type: 'date', + array: false, + required: false, + }, + 'tls.client.not_before': { + type: 'date', + array: false, + required: false, + }, + 'tls.client.server_name': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.subject': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.supported_ciphers': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.alternative_names': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.country': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.issuer.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.issuer.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.not_after': { + type: 'date', + array: false, + required: false, + }, + 'tls.client.x509.not_before': { + type: 'date', + array: false, + required: false, + }, + 'tls.client.x509.public_key_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.public_key_curve': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.public_key_exponent': { + type: 'long', + array: false, + required: false, + }, + 'tls.client.x509.public_key_size': { + type: 'long', + array: false, + required: false, + }, + 'tls.client.x509.serial_number': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.signature_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.subject.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.subject.country': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.subject.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.client.x509.subject.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.subject.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.subject.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.subject.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.client.x509.version_number': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.curve': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.established': { + type: 'boolean', + array: false, + required: false, + }, + 'tls.next_protocol': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.resumed': { + type: 'boolean', + array: false, + required: false, + }, + 'tls.server.certificate': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.certificate_chain': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.hash.md5': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.hash.sha1': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.hash.sha256': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.issuer': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.ja3s': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.not_after': { + type: 'date', + array: false, + required: false, + }, + 'tls.server.not_before': { + type: 'date', + array: false, + required: false, + }, + 'tls.server.subject': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.alternative_names': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.country': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.issuer.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.issuer.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.not_after': { + type: 'date', + array: false, + required: false, + }, + 'tls.server.x509.not_before': { + type: 'date', + array: false, + required: false, + }, + 'tls.server.x509.public_key_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.public_key_curve': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.public_key_exponent': { + type: 'long', + array: false, + required: false, + }, + 'tls.server.x509.public_key_size': { + type: 'long', + array: false, + required: false, + }, + 'tls.server.x509.serial_number': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.signature_algorithm': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.subject.common_name': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.subject.country': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.subject.distinguished_name': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.server.x509.subject.locality': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.subject.organization': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.subject.organizational_unit': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.subject.state_or_province': { + type: 'keyword', + array: true, + required: false, + }, + 'tls.server.x509.version_number': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.version': { + type: 'keyword', + array: false, + required: false, + }, + 'tls.version_protocol': { + type: 'keyword', + array: false, + required: false, + }, + 'trace.id': { + type: 'keyword', + array: false, + required: false, + }, + 'transaction.id': { + type: 'keyword', + array: false, + required: false, + }, + 'url.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'url.extension': { + type: 'keyword', + array: false, + required: false, + }, + 'url.fragment': { + type: 'keyword', + array: false, + required: false, + }, + 'url.full': { + type: 'keyword', + array: false, + required: false, + }, + 'url.original': { + type: 'keyword', + array: false, + required: false, + }, + 'url.password': { + type: 'keyword', + array: false, + required: false, + }, + 'url.path': { + type: 'keyword', + array: false, + required: false, + }, + 'url.port': { + type: 'long', + array: false, + required: false, + }, + 'url.query': { + type: 'keyword', + array: false, + required: false, + }, + 'url.registered_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'url.scheme': { + type: 'keyword', + array: false, + required: false, + }, + 'url.subdomain': { + type: 'keyword', + array: false, + required: false, + }, + 'url.top_level_domain': { + type: 'keyword', + array: false, + required: false, + }, + 'url.username': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.email': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.changes.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'user.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.email': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.effective.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'user.email': { + type: 'keyword', + array: false, + required: false, + }, + 'user.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'user.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'user.target.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.email': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.full_name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.group.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.group.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.group.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.hash': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.id': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user.target.roles': { + type: 'keyword', + array: true, + required: false, + }, + 'user_agent.device.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.original': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.family': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.full': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.kernel': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.name': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.platform': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.type': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.os.version': { + type: 'keyword', + array: false, + required: false, + }, + 'user_agent.version': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.category': { + type: 'keyword', + array: true, + required: false, + }, + 'vulnerability.classification': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.description': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.enumeration': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.id': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.reference': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.report_id': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.scanner.vendor': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.score.base': { + type: 'float', + array: false, + required: false, + }, + 'vulnerability.score.environmental': { + type: 'float', + array: false, + required: false, + }, + 'vulnerability.score.temporal': { + type: 'float', + array: false, + required: false, + }, + 'vulnerability.score.version': { + type: 'keyword', + array: false, + required: false, + }, + 'vulnerability.severity': { + type: 'keyword', + array: false, + required: false, + }, +} as const; diff --git a/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json b/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json new file mode 100644 index 0000000000000..f7cbfc3dfaae3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json @@ -0,0 +1,3416 @@ +{ + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "build": { + "properties": { + "original": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reason": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "x509": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_exponent": { + "doc_values": false, + "index": false, + "type": "long" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "file": { + "properties": { + "path": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + }, + "type": "object" + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + }, + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "hosts": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "span": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "subtechnique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + }, + "x509": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_exponent": { + "doc_values": false, + "index": false, + "type": "long" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "x509": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_exponent": { + "doc_values": false, + "index": false, + "type": "long" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "distinguished_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "state_or_province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "changes": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "effective": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "roles": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts new file mode 100644 index 0000000000000..7c46717300819 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from 'src/core/server'; +import { RuleRegistryPlugin } from './plugin'; + +export { RuleRegistryPluginSetupContract } from './plugin'; +export { createLifecycleRuleTypeFactory } from './rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory'; +export { ecsFieldMap } from './generated/ecs_field_map'; +export { pickWithPatterns } from './rule_registry/field_map/pick_with_patterns'; +export { FieldMapOf } from './types'; +export { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + writeEnabled: schema.boolean({ defaultValue: false }), + }), +}; + +export type RuleRegistryConfig = TypeOf; + +export const plugin = (initContext: PluginInitializerContext) => + new RuleRegistryPlugin(initContext); diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts new file mode 100644 index 0000000000000..9e83d938d508b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { PluginSetupContract as AlertingPluginSetupContract } from '../../alerting/server'; +import { RuleRegistry } from './rule_registry'; +import { defaultIlmPolicy } from './rule_registry/defaults/ilm_policy'; +import { defaultFieldMap } from './rule_registry/defaults/field_map'; +import { RuleRegistryConfig } from '.'; + +export type RuleRegistryPluginSetupContract = RuleRegistry; + +export class RuleRegistryPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + } + + public setup( + core: CoreSetup, + plugins: { alerting: AlertingPluginSetupContract } + ): RuleRegistryPluginSetupContract { + const globalConfig = this.initContext.config.legacy.get(); + const config = this.initContext.config.get(); + + const logger = this.initContext.logger.get(); + + const rootRegistry = new RuleRegistry({ + coreSetup: core, + ilmPolicy: defaultIlmPolicy, + fieldMap: defaultFieldMap, + kibanaIndex: globalConfig.kibana.index, + name: 'alerts', + kibanaVersion: this.initContext.env.packageInfo.version, + logger: logger.get('root'), + alertingPluginSetupContract: plugins.alerting, + writeEnabled: config.writeEnabled, + }); + + return rootRegistry; + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts new file mode 100644 index 0000000000000..9a3d4a38d2ad6 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/index.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Either, isLeft, isRight } from 'fp-ts/lib/Either'; +import { Errors } from 'io-ts'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { Logger, SavedObjectsClientContract } from 'kibana/server'; +import { IScopedClusterClient as ScopedClusterClient } from 'src/core/server'; +import { compact } from 'lodash'; +import { ESSearchRequest } from 'typings/elasticsearch'; +import { ClusterClientAdapter } from '../../../../event_log/server'; +import { runtimeTypeFromFieldMap, OutputOfFieldMap } from '../field_map/runtime_type_from_fieldmap'; +import { ScopedRuleRegistryClient, EventsOf } from './types'; +import { DefaultFieldMap } from '../defaults/field_map'; + +const getRuleUuids = async ({ + savedObjectsClient, + namespace, +}: { + savedObjectsClient: SavedObjectsClientContract; + namespace?: string; +}) => { + const options = { + type: 'alert', + ...(namespace ? { namespace } : {}), + }; + + const pitFinder = savedObjectsClient.createPointInTimeFinder({ + ...options, + }); + + const ruleUuids: string[] = []; + + for await (const response of pitFinder.find()) { + ruleUuids.push(...response.saved_objects.map((object) => object.id)); + } + + await pitFinder.close(); + + return ruleUuids; +}; + +const createPathReporterError = (either: Either) => { + const error = new Error(`Failed to validate alert event`); + error.stack += '\n' + PathReporter.report(either).join('\n'); + return error; +}; + +export function createScopedRuleRegistryClient({ + fieldMap, + scopedClusterClient, + savedObjectsClient, + namespace, + clusterClientAdapter, + indexAliasName, + indexTarget, + logger, + ruleData, +}: { + fieldMap: TFieldMap; + scopedClusterClient: ScopedClusterClient; + savedObjectsClient: SavedObjectsClientContract; + namespace?: string; + clusterClientAdapter: ClusterClientAdapter<{ + body: OutputOfFieldMap; + index: string; + }>; + indexAliasName: string; + indexTarget: string; + logger: Logger; + ruleData?: { + rule: { + id: string; + uuid: string; + category: string; + name: string; + }; + producer: string; + tags: string[]; + }; +}): ScopedRuleRegistryClient { + const docRt = runtimeTypeFromFieldMap(fieldMap); + + const defaults: Partial> = ruleData + ? { + 'rule.uuid': ruleData.rule.uuid, + 'rule.id': ruleData.rule.id, + 'rule.name': ruleData.rule.name, + 'rule.category': ruleData.rule.category, + 'kibana.rac.producer': ruleData.producer, + tags: ruleData.tags, + } + : {}; + + const client: ScopedRuleRegistryClient = { + search: async (searchRequest) => { + const ruleUuids = await getRuleUuids({ + savedObjectsClient, + namespace, + }); + + const response = await scopedClusterClient.asInternalUser.search({ + ...searchRequest, + index: indexTarget, + body: { + ...searchRequest.body, + query: { + bool: { + filter: [ + { terms: { 'rule.uuid': ruleUuids } }, + ...(searchRequest.body?.query ? [searchRequest.body.query] : []), + ], + }, + }, + }, + }); + + return { + body: response.body as any, + events: compact( + response.body.hits.hits.map((hit) => { + const validation = docRt.decode(hit.fields); + if (isLeft(validation)) { + const error = createPathReporterError(validation); + logger.error(error); + return undefined; + } + return docRt.encode(validation.right); + }) + ) as EventsOf, + }; + }, + index: (doc) => { + const validation = docRt.decode({ + ...doc, + ...defaults, + }); + + if (isLeft(validation)) { + throw createPathReporterError(validation); + } + + clusterClientAdapter.indexDocument({ body: validation.right, index: indexAliasName }); + }, + bulkIndex: (docs) => { + const validations = docs.map((doc) => { + return docRt.decode({ + ...doc, + ...defaults, + }); + }); + + const errors = compact( + validations.map((validation) => + isLeft(validation) ? createPathReporterError(validation) : null + ) + ); + + errors.forEach((error) => { + logger.error(error); + }); + + const operations = compact( + validations.map((validation) => (isRight(validation) ? validation.right : null)) + ).map((doc) => ({ body: doc, index: indexAliasName })); + + return clusterClientAdapter.indexDocuments(operations); + }, + }; + return client; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts new file mode 100644 index 0000000000000..95aa180709a51 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ESSearchRequest, ESSearchResponse } from 'typings/elasticsearch'; +import { DefaultFieldMap } from '../defaults/field_map'; +import { PatternsUnionOf, PickWithPatterns } from '../field_map/pick_with_patterns'; +import { OutputOfFieldMap } from '../field_map/runtime_type_from_fieldmap'; + +export type PrepopulatedRuleEventFields = + | 'rule.uuid' + | 'rule.id' + | 'rule.name' + | 'rule.type' + | 'rule.category' + | 'producer'; + +type FieldsOf = + | Array<{ field: PatternsUnionOf } | PatternsUnionOf> + | PatternsUnionOf; + +type Fields = Array<{ field: TPattern } | TPattern> | TPattern; + +type FieldsESSearchRequest = ESSearchRequest & { + body?: { fields: FieldsOf }; +}; + +export type EventsOf< + TFieldsESSearchRequest extends ESSearchRequest, + TFieldMap extends DefaultFieldMap +> = TFieldsESSearchRequest extends { body: { fields: infer TFields } } + ? TFields extends Fields + ? Array>> + : never + : never; + +export interface ScopedRuleRegistryClient { + search>( + request: TSearchRequest + ): Promise<{ + body: ESSearchResponse; + events: EventsOf; + }>; + index(doc: Omit, PrepopulatedRuleEventFields>): void; + bulkIndex( + doc: Array, PrepopulatedRuleEventFields>> + ): Promise; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts b/x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts new file mode 100644 index 0000000000000..db851b7b94c76 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ecsFieldMap } from '../../generated/ecs_field_map'; +import { pickWithPatterns } from '../field_map/pick_with_patterns'; + +export const defaultFieldMap = { + ...pickWithPatterns( + ecsFieldMap, + '@timestamp', + 'event.kind', + 'event.action', + 'rule.uuid', + 'rule.id', + 'rule.name', + 'rule.category', + 'tags' + ), + 'kibana.rac.producer': { type: 'keyword' }, + 'kibana.rac.alert.uuid': { type: 'keyword' }, + 'kibana.rac.alert.id': { type: 'keyword' }, + 'kibana.rac.alert.start': { type: 'date' }, + 'kibana.rac.alert.end': { type: 'date' }, + 'kibana.rac.alert.duration.us': { type: 'long' }, + 'kibana.rac.alert.severity.level': { type: 'keyword' }, + 'kibana.rac.alert.severity.value': { type: 'long' }, + 'kibana.rac.alert.status': { type: 'keyword' }, +} as const; + +export type DefaultFieldMap = typeof defaultFieldMap; diff --git a/x-pack/plugins/rule_registry/server/rule_registry/defaults/ilm_policy.ts b/x-pack/plugins/rule_registry/server/rule_registry/defaults/ilm_policy.ts new file mode 100644 index 0000000000000..c80f7e772f308 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/defaults/ilm_policy.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILMPolicy } from '../types'; + +export const defaultIlmPolicy: ILMPolicy = { + policy: { + phases: { + hot: { + actions: { + rollover: { + max_age: '90d', + max_size: '50gb', + }, + }, + }, + delete: { + actions: { + delete: {}, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts new file mode 100644 index 0000000000000..6e4e13b01d2c5 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from '@elastic/safer-lodash-set'; +import { FieldMap, Mappings } from '../types'; + +export function mappingFromFieldMap(fieldMap: FieldMap): Mappings { + const mappings = { + dynamic: 'strict' as const, + properties: {}, + }; + + const fields = Object.keys(fieldMap).map((key) => { + const field = fieldMap[key]; + return { + name: key, + ...field, + }; + }); + + fields.forEach((field) => { + const { name, required, array, ...rest } = field; + + set(mappings.properties, field.name.split('.').join('.properties.'), rest); + }); + + return mappings; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts new file mode 100644 index 0000000000000..e15b228b0f287 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import util from 'util'; +import { FieldMap } from '../types'; + +export function mergeFieldMaps( + first: T1, + second: T2 +): T1 & T2 { + const conflicts: Array> = []; + + Object.keys(second).forEach((name) => { + const field = second[name]; + + const parts = name.split('.'); + + const parents = parts.slice(0, parts.length - 2).map((part, index, array) => { + return [...array.slice(0, index - 1), part].join('.'); + }); + + parents + .filter((parent) => first[parent] !== undefined) + .forEach((parent) => { + conflicts.push({ + [parent]: [{ type: 'object' }, first[parent]!], + }); + }); + + if (first[name]) { + conflicts.push({ + [name]: [field, first[name]], + }); + } + }); + + if (conflicts.length) { + const err = new Error(`Could not merge mapping due to conflicts`); + Object.assign(err, { conflicts: util.inspect(conflicts, { depth: null }) }); + throw err; + } + + return { + ...first, + ...second, + }; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts new file mode 100644 index 0000000000000..48ba7c873db25 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pickWithPatterns } from './pick_with_patterns'; + +describe('pickWithPatterns', () => { + const fieldMap = { + 'event.category': { type: 'keyword' }, + 'event.kind': { type: 'keyword' }, + 'destination.bytes': { + type: 'long', + array: false, + required: false, + }, + 'destination.domain': { + type: 'keyword', + array: false, + required: false, + }, + 'destination.geo.city_name': { + type: 'keyword', + array: false, + required: false, + }, + } as const; + + it('picks a single field', () => { + expect(Object.keys(pickWithPatterns(fieldMap, 'event.category'))).toEqual(['event.category']); + }); + + it('picks event fields', () => { + expect(Object.keys(pickWithPatterns(fieldMap, 'event.*')).sort()).toEqual([ + 'event.category', + 'event.kind', + ]); + }); + + it('picks destination.geo fields', () => { + expect(Object.keys(pickWithPatterns(fieldMap, 'destination.geo.*')).sort()).toEqual([ + 'destination.geo.city_name', + ]); + }); + + it('picks all destination fields', () => { + expect(Object.keys(pickWithPatterns(fieldMap, 'destination.*')).sort()).toEqual([ + 'destination.bytes', + 'destination.domain', + 'destination.geo.city_name', + ]); + }); + + it('picks fields from multiple patterns', () => { + expect( + Object.keys(pickWithPatterns(fieldMap, 'destination.geo.*', 'event.category')).sort() + ).toEqual(['destination.geo.city_name', 'event.category']); + }); + + it('picks all fields', () => { + expect(Object.keys(pickWithPatterns(fieldMap, '*')).sort()).toEqual([ + 'destination.bytes', + 'destination.domain', + 'destination.geo.city_name', + 'event.category', + 'event.kind', + ]); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.ts new file mode 100644 index 0000000000000..f8a88957fceb5 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ValuesType, SetIntersection, OmitByValueExact } from 'utility-types'; +import { pick } from 'lodash'; + +type SplitByDot< + TPath extends string, + TPrefix extends string = '' +> = TPath extends `${infer TKey}.${infer TRest}` + ? [`${TPrefix}${TKey}.*`, ...SplitByDot] + : [`${TPrefix}${TPath}`]; + +type PatternMapOf> = { + [TKey in keyof T]: ValuesType] : never>; +}; + +export type PickWithPatterns< + T extends Record, + TPatterns extends string[] +> = OmitByValueExact< + { + [TFieldName in keyof T]: SetIntersection< + ValuesType, + PatternMapOf[TFieldName] + > extends never + ? never + : T[TFieldName]; + }, + never +>; + +export type PatternsUnionOf> = '*' | ValuesType>; + +export function pickWithPatterns< + T extends Record, + TPatterns extends Array> +>(map: T, ...patterns: TPatterns): PickWithPatterns { + const allFields = Object.keys(map); + const matchedFields = allFields.filter((field) => + patterns.some((pattern) => { + if (pattern === field) { + return true; + } + + const fieldParts = field.split('.'); + const patternParts = pattern.split('.'); + + if (patternParts.indexOf('*') !== patternParts.length - 1) { + return false; + } + + return fieldParts.every((fieldPart, index) => { + const patternPart = patternParts.length - 1 < index ? '*' : patternParts[index]; + + return fieldPart === patternPart || patternPart === '*'; + }); + }) + ); + + return (pick(map, matchedFields) as unknown) as PickWithPatterns; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.test.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.test.ts new file mode 100644 index 0000000000000..0acf80bfb42e5 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { runtimeTypeFromFieldMap } from './runtime_type_from_fieldmap'; + +describe('runtimeTypeFromFieldMap', () => { + const fieldmapRt = runtimeTypeFromFieldMap({ + keywordField: { type: 'keyword' }, + longField: { type: 'long' }, + requiredKeywordField: { type: 'keyword', required: true }, + multiKeywordField: { type: 'keyword', array: true }, + } as const); + + it('accepts both singular and array fields', () => { + expect( + fieldmapRt.is({ + requiredKeywordField: 'keyword', + }) + ).toBe(true); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + }) + ).toBe(true); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + multiKeywordField: 'keyword', + }) + ).toBe(true); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + multiKeywordField: ['keyword'], + }) + ).toBe(true); + }); + + it('fails on invalid data types', () => { + expect( + fieldmapRt.is({ + requiredKeywordField: 2, + }) + ).toBe(false); + + expect( + fieldmapRt.is({ + requiredKeywordField: [2], + }) + ).toBe(false); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + longField: ['keyword'], + }) + ).toBe(false); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + longField: [3], + }) + ).toBe(true); + + expect( + fieldmapRt.is({ + requiredKeywordField: ['keyword'], + longField: 3, + }) + ).toBe(true); + }); + + it('outputs to single or array values', () => { + expect( + fieldmapRt.encode({ + requiredKeywordField: ['required'], + keywordField: 'keyword', + longField: [3, 2], + multiKeywordField: ['keyword', 'foo'], + }) + ).toEqual({ + requiredKeywordField: 'required', + keywordField: 'keyword', + longField: 3, + multiKeywordField: ['keyword', 'foo'], + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts new file mode 100644 index 0000000000000..6dc557c016d1a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mapValues, pickBy } from 'lodash'; +import * as t from 'io-ts'; +import { Mutable, PickByValueExact } from 'utility-types'; +import { FieldMap } from '../types'; + +const esFieldTypeMap = { + keyword: t.string, + text: t.string, + date: t.string, + boolean: t.boolean, + byte: t.number, + long: t.number, + integer: t.number, + short: t.number, + double: t.number, + float: t.number, + scaled_float: t.number, + unsigned_long: t.number, + flattened: t.record(t.string, t.array(t.string)), +}; + +type EsFieldTypeMap = typeof esFieldTypeMap; + +type EsFieldTypeOf = T extends keyof EsFieldTypeMap + ? EsFieldTypeMap[T] + : t.UnknownC; + +type RequiredKeysOf> = keyof PickByValueExact< + { + [key in keyof T]: T[key]['required']; + }, + true +>; + +type IntersectionTypeOf< + T extends Record +> = t.IntersectionC< + [ + t.TypeC>>, + t.PartialC<{ [key in keyof T]: T[key]['type'] }> + ] +>; + +type CastArray> = t.Type< + t.TypeOf | Array>, + Array>, + unknown +>; +type CastSingle> = t.Type< + t.TypeOf | Array>, + t.TypeOf, + unknown +>; + +const createCastArrayRt = >(type: T): CastArray => { + const union = t.union([type, t.array(type)]); + + return new t.Type('castArray', union.is, union.validate, (a) => (Array.isArray(a) ? a : [a])); +}; + +const createCastSingleRt = >(type: T): CastSingle => { + const union = t.union([type, t.array(type)]); + + return new t.Type('castSingle', union.is, union.validate, (a) => (Array.isArray(a) ? a[0] : a)); +}; + +type MapTypeValues = { + [key in keyof T]: { + required: T[key]['required']; + type: T[key]['array'] extends true + ? CastArray> + : CastSingle>; + }; +}; + +type FieldMapType = IntersectionTypeOf>; + +export type TypeOfFieldMap = Mutable>>; +export type OutputOfFieldMap = Mutable>>; + +export function runtimeTypeFromFieldMap( + fieldMap: TFieldMap +): FieldMapType { + function mapToType(fields: FieldMap) { + return mapValues(fields, (field, key) => { + const type = + field.type in esFieldTypeMap + ? esFieldTypeMap[field.type as keyof EsFieldTypeMap] + : t.unknown; + + return field.array ? createCastArrayRt(type) : createCastSingleRt(type); + }); + } + + const required = pickBy(fieldMap, (field) => field.required); + + return (t.intersection([ + t.exact(t.partial(mapToType(fieldMap))), + t.type(mapToType(required)), + ]) as unknown) as FieldMapType; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/index.ts b/x-pack/plugins/rule_registry/server/rule_registry/index.ts new file mode 100644 index 0000000000000..f1d24550ade0a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/index.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, Logger, RequestHandlerContext } from 'kibana/server'; +import { inspect } from 'util'; +import { SpacesServiceStart } from '../../../spaces/server'; +import { + ActionVariable, + AlertInstanceState, + AlertTypeParams, + AlertTypeState, +} from '../../../alerting/common'; +import { createReadySignal, ClusterClientAdapter } from '../../../event_log/server'; +import { FieldMap, ILMPolicy } from './types'; +import { RuleParams, RuleType } from '../types'; +import { mergeFieldMaps } from './field_map/merge_field_maps'; +import { OutputOfFieldMap } from './field_map/runtime_type_from_fieldmap'; +import { mappingFromFieldMap } from './field_map/mapping_from_field_map'; +import { PluginSetupContract as AlertingPluginSetupContract } from '../../../alerting/server'; +import { createScopedRuleRegistryClient } from './create_scoped_rule_registry_client'; +import { DefaultFieldMap } from './defaults/field_map'; +import { ScopedRuleRegistryClient } from './create_scoped_rule_registry_client/types'; + +interface RuleRegistryOptions { + kibanaIndex: string; + kibanaVersion: string; + name: string; + logger: Logger; + coreSetup: CoreSetup; + spacesStart?: SpacesServiceStart; + fieldMap: TFieldMap; + ilmPolicy: ILMPolicy; + alertingPluginSetupContract: AlertingPluginSetupContract; + writeEnabled: boolean; +} + +export class RuleRegistry { + private readonly esAdapter: ClusterClientAdapter<{ + body: OutputOfFieldMap; + index: string; + }>; + private readonly children: Array> = []; + + constructor(private readonly options: RuleRegistryOptions) { + const { logger, coreSetup } = options; + + const { wait, signal } = createReadySignal(); + + this.esAdapter = new ClusterClientAdapter<{ + body: OutputOfFieldMap; + index: string; + }>({ + wait, + elasticsearchClientPromise: coreSetup + .getStartServices() + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), + logger: logger.get('esAdapter'), + }); + + if (this.options.writeEnabled) { + this.initialize() + .then(() => { + this.options.logger.debug('Bootstrapped alerts index'); + signal(true); + }) + .catch((err) => { + logger.error(inspect(err, { depth: null })); + signal(false); + }); + } else { + logger.debug('Write disabled, indices are not being bootstrapped'); + } + } + + private getEsNames() { + const base = [this.options.kibanaIndex, this.options.name]; + const indexTarget = `${base.join('-')}*`; + const indexAliasName = [...base, this.options.kibanaVersion.toLowerCase()].join('-'); + const policyName = [...base, 'policy'].join('-'); + + return { + indexAliasName, + indexTarget, + policyName, + }; + } + + private async initialize() { + const { indexAliasName, policyName } = this.getEsNames(); + + const ilmPolicyExists = await this.esAdapter.doesIlmPolicyExist(policyName); + + if (!ilmPolicyExists) { + await this.esAdapter.createIlmPolicy( + policyName, + (this.options.ilmPolicy as unknown) as Record + ); + } + + const templateExists = await this.esAdapter.doesIndexTemplateExist(indexAliasName); + + if (!templateExists) { + await this.esAdapter.createIndexTemplate(indexAliasName, { + index_patterns: [`${indexAliasName}-*`], + settings: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + 'index.lifecycle.name': policyName, + 'index.lifecycle.rollover_alias': indexAliasName, + 'sort.field': '@timestamp', + 'sort.order': 'desc', + }, + mappings: mappingFromFieldMap(this.options.fieldMap), + }); + } + + const aliasExists = await this.esAdapter.doesAliasExist(indexAliasName); + + if (!aliasExists) { + await this.esAdapter.createIndex(`${indexAliasName}-000001`, { + aliases: { + [indexAliasName]: { + is_write_index: true, + }, + }, + }); + } + } + + createScopedRuleRegistryClient({ + context, + }: { + context: RequestHandlerContext; + }): ScopedRuleRegistryClient | undefined { + if (!this.options.writeEnabled) { + return undefined; + } + const { indexAliasName, indexTarget } = this.getEsNames(); + + return createScopedRuleRegistryClient({ + savedObjectsClient: context.core.savedObjects.getClient({ includedHiddenTypes: ['alert'] }), + scopedClusterClient: context.core.elasticsearch.client, + clusterClientAdapter: this.esAdapter, + fieldMap: this.options.fieldMap, + indexAliasName, + indexTarget, + logger: this.options.logger, + }); + } + + registerType( + type: RuleType + ) { + const logger = this.options.logger.get(type.id); + + const { indexAliasName, indexTarget } = this.getEsNames(); + + this.options.alertingPluginSetupContract.registerType< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + { [key in TActionVariable['name']]: any }, + string + >({ + ...type, + executor: async (executorOptions) => { + const { services, namespace, alertId, name, tags } = executorOptions; + + const rule = { + id: type.id, + uuid: alertId, + category: type.name, + name, + }; + + const producer = type.producer; + + return type.executor({ + ...executorOptions, + rule, + producer, + services: { + ...services, + logger, + ...(this.options.writeEnabled + ? { + scopedRuleRegistryClient: createScopedRuleRegistryClient({ + savedObjectsClient: services.savedObjectsClient, + scopedClusterClient: services.scopedClusterClient, + clusterClientAdapter: this.esAdapter, + fieldMap: this.options.fieldMap, + indexAliasName, + indexTarget, + namespace, + ruleData: { + producer, + rule, + tags, + }, + logger: this.options.logger, + }), + } + : {}), + }, + }); + }, + }); + } + + create({ + name, + fieldMap, + ilmPolicy, + }: { + name: string; + fieldMap: TNextFieldMap; + ilmPolicy?: ILMPolicy; + }): RuleRegistry { + const mergedFieldMap = fieldMap + ? mergeFieldMaps(this.options.fieldMap, fieldMap) + : this.options.fieldMap; + + const child = new RuleRegistry({ + ...this.options, + logger: this.options.logger.get(name), + name: [this.options.name, name].filter(Boolean).join('-'), + fieldMap: mergedFieldMap, + ...(ilmPolicy ? { ilmPolicy } : {}), + }); + + this.children.push(child); + + // @ts-expect-error could be instantiated with a different subtype of constraint + return child; + } +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts new file mode 100644 index 0000000000000..9c64e85f839bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { isLeft } from 'fp-ts/lib/Either'; +import v4 from 'uuid/v4'; +import { AlertInstance } from '../../../../alerting/server'; +import { ActionVariable, AlertInstanceState } from '../../../../alerting/common'; +import { RuleParams, RuleType } from '../../types'; +import { DefaultFieldMap } from '../defaults/field_map'; +import { OutputOfFieldMap } from '../field_map/runtime_type_from_fieldmap'; +import { PrepopulatedRuleEventFields } from '../create_scoped_rule_registry_client/types'; +import { RuleRegistry } from '..'; + +type UserDefinedAlertFields = Omit< + OutputOfFieldMap, + PrepopulatedRuleEventFields | 'kibana.rac.alert.id' | 'kibana.rac.alert.uuid' | '@timestamp' +>; + +type LifecycleAlertService< + TFieldMap extends DefaultFieldMap, + TActionVariable extends ActionVariable +> = (alert: { + id: string; + fields: UserDefinedAlertFields; +}) => AlertInstance; + +type CreateLifecycleRuleType = < + TRuleParams extends RuleParams, + TActionVariable extends ActionVariable +>( + type: RuleType< + TFieldMap, + TRuleParams, + TActionVariable, + { alertWithLifecycle: LifecycleAlertService } + > +) => RuleType; + +const trackedAlertStateRt = t.type({ + alertId: t.string, + alertUuid: t.string, + started: t.string, +}); + +const wrappedStateRt = t.type({ + wrapped: t.record(t.string, t.unknown), + trackedAlerts: t.record(t.string, trackedAlertStateRt), +}); + +export function createLifecycleRuleTypeFactory< + TRuleRegistry extends RuleRegistry +>(): TRuleRegistry extends RuleRegistry + ? CreateLifecycleRuleType + : never; + +export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType { + return (type) => { + return { + ...type, + executor: async (options) => { + const { + services: { scopedRuleRegistryClient, alertInstanceFactory, logger }, + state: previousState, + rule, + } = options; + + const decodedState = wrappedStateRt.decode(previousState); + + const state = isLeft(decodedState) + ? { + wrapped: previousState, + trackedAlerts: {}, + } + : decodedState.right; + + const currentAlerts: Record< + string, + UserDefinedAlertFields & { 'kibana.rac.alert.id': string } + > = {}; + + const timestamp = options.startedAt.toISOString(); + + const nextWrappedState = await type.executor({ + ...options, + state: state.wrapped, + services: { + ...options.services, + alertWithLifecycle: ({ id, fields }) => { + currentAlerts[id] = { + ...fields, + 'kibana.rac.alert.id': id, + }; + return alertInstanceFactory(id); + }, + }, + }); + + const currentAlertIds = Object.keys(currentAlerts); + const trackedAlertIds = Object.keys(state.trackedAlerts); + const newAlertIds = currentAlertIds.filter((alertId) => !trackedAlertIds.includes(alertId)); + + const allAlertIds = [...new Set(currentAlertIds.concat(trackedAlertIds))]; + + const trackedAlertStatesOfRecovered = Object.values(state.trackedAlerts).filter( + (trackedAlertState) => !currentAlerts[trackedAlertState.alertId] + ); + + logger.debug( + `Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStatesOfRecovered.length} recovered)` + ); + + const alertsDataMap: Record> = { + ...currentAlerts, + }; + + if (scopedRuleRegistryClient && trackedAlertStatesOfRecovered.length) { + const { events } = await scopedRuleRegistryClient.search({ + body: { + query: { + bool: { + filter: [ + { + term: { + 'rule.uuid': rule.uuid, + }, + }, + { + terms: { + 'kibana.rac.alert.uuid': trackedAlertStatesOfRecovered.map( + (trackedAlertState) => trackedAlertState.alertUuid + ), + }, + }, + ], + }, + }, + size: trackedAlertStatesOfRecovered.length, + collapse: { + field: 'kibana.rac.alert.uuid', + }, + _source: false, + fields: ['*'], + sort: { + '@timestamp': 'desc' as const, + }, + }, + }); + + events.forEach((event) => { + const alertId = event['kibana.rac.alert.id']!; + alertsDataMap[alertId] = event; + }); + } + + const eventsToIndex: Array> = allAlertIds.map( + (alertId) => { + const alertData = alertsDataMap[alertId]; + + if (!alertData) { + logger.warn(`Could not find alert data for ${alertId}`); + } + + const event: OutputOfFieldMap = { + ...alertData, + '@timestamp': timestamp, + 'event.kind': 'state', + 'kibana.rac.alert.id': alertId, + }; + + const isNew = !state.trackedAlerts[alertId]; + const isRecovered = !currentAlerts[alertId]; + const isActiveButNotNew = !isNew && !isRecovered; + const isActive = !isRecovered; + + const { alertUuid, started } = state.trackedAlerts[alertId] ?? { + alertUuid: v4(), + started: timestamp, + }; + + event['kibana.rac.alert.start'] = started; + event['kibana.rac.alert.uuid'] = alertUuid; + + if (isNew) { + event['event.action'] = 'open'; + } + + if (isRecovered) { + event['kibana.rac.alert.end'] = timestamp; + event['event.action'] = 'close'; + event['kibana.rac.alert.status'] = 'closed'; + } + + if (isActiveButNotNew) { + event['event.action'] = 'active'; + } + + if (isActive) { + event['kibana.rac.alert.status'] = 'open'; + } + + event['kibana.rac.alert.duration.us'] = + (options.startedAt.getTime() - new Date(event['kibana.rac.alert.start']!).getTime()) * + 1000; + + return event; + } + ); + + if (eventsToIndex.length && scopedRuleRegistryClient) { + await scopedRuleRegistryClient.bulkIndex(eventsToIndex); + } + + const nextTrackedAlerts = Object.fromEntries( + eventsToIndex + .filter((event) => event['kibana.rac.alert.status'] !== 'closed') + .map((event) => { + const alertId = event['kibana.rac.alert.id']!; + const alertUuid = event['kibana.rac.alert.uuid']!; + const started = new Date(event['kibana.rac.alert.start']!).toISOString(); + return [alertId, { alertId, alertUuid, started }]; + }) + ); + + return { + wrapped: nextWrappedState, + trackedAlerts: nextTrackedAlerts, + }; + }, + }; + }; +} diff --git a/x-pack/plugins/rule_registry/server/rule_registry/types.ts b/x-pack/plugins/rule_registry/server/rule_registry/types.ts new file mode 100644 index 0000000000000..f6baf8bcecbd0 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_registry/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface Mappings { + dynamic: 'strict' | boolean; + properties: Record; +} + +enum ILMPolicyPhase { + hot = 'hot', + delete = 'delete', +} + +enum ILMPolicyAction { + rollover = 'rollover', + delete = 'delete', +} + +interface ILMActionOptions { + [ILMPolicyAction.rollover]: { + max_size: string; + max_age: string; + }; + [ILMPolicyAction.delete]: {}; +} + +export interface ILMPolicy { + policy: { + phases: Record< + ILMPolicyPhase, + { + actions: { + [key in keyof ILMActionOptions]?: ILMActionOptions[key]; + }; + } + >; + }; +} + +export type FieldMap = Record; diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts new file mode 100644 index 0000000000000..e6b53a8558964 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Type, TypeOf } from '@kbn/config-schema'; +import { Logger } from 'kibana/server'; +import { + ActionVariable, + AlertInstanceContext, + AlertInstanceState, + AlertTypeParams, + AlertTypeState, +} from '../../alerting/common'; +import { ActionGroup, AlertExecutorOptions } from '../../alerting/server'; +import { RuleRegistry } from './rule_registry'; +import { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; +import { DefaultFieldMap } from './rule_registry/defaults/field_map'; + +export type RuleParams = Type; + +type TypeOfRuleParams = TypeOf; + +type RuleExecutorServices< + TFieldMap extends DefaultFieldMap, + TActionVariable extends ActionVariable +> = AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + { [key in TActionVariable['name']]: any }, + string +>['services'] & { + logger: Logger; + scopedRuleRegistryClient?: ScopedRuleRegistryClient; +}; + +type PassthroughAlertExecutorOptions = Pick< + AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >, + 'previousStartedAt' | 'startedAt' | 'state' +>; + +type RuleExecutorFunction< + TFieldMap extends DefaultFieldMap, + TRuleParams extends RuleParams, + TActionVariable extends ActionVariable, + TAdditionalRuleExecutorServices extends Record +> = ( + options: PassthroughAlertExecutorOptions & { + services: RuleExecutorServices & TAdditionalRuleExecutorServices; + params: TypeOfRuleParams; + rule: { + id: string; + uuid: string; + name: string; + category: string; + }; + producer: string; + } +) => Promise>; + +interface RuleTypeBase { + id: string; + name: string; + actionGroups: Array>; + defaultActionGroupId: string; + producer: string; + minimumLicenseRequired: 'basic' | 'gold' | 'trial'; +} + +export type RuleType< + TFieldMap extends DefaultFieldMap, + TRuleParams extends RuleParams, + TActionVariable extends ActionVariable, + TAdditionalRuleExecutorServices extends Record = {} +> = RuleTypeBase & { + validate: { + params: TRuleParams; + }; + actionVariables: { + context: TActionVariable[]; + }; + executor: RuleExecutorFunction< + TFieldMap, + TRuleParams, + TActionVariable, + TAdditionalRuleExecutorServices + >; +}; + +export type FieldMapOf< + TRuleRegistry extends RuleRegistry +> = TRuleRegistry extends RuleRegistry ? TFieldMap : never; diff --git a/x-pack/plugins/rule_registry/tsconfig.json b/x-pack/plugins/rule_registry/tsconfig.json new file mode 100644 index 0000000000000..2961abe6cfecd --- /dev/null +++ b/x-pack/plugins/rule_registry/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "server/**/*", "../../../typings/**/*"], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../alerting/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 3002db642bc1e..5df73f7f8ec4e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -480,23 +480,25 @@ describe('', () => { }); }); - it('can render if features are not available', async () => { - const { http } = coreMock.createStart(); - http.get.mockImplementation(async (path: any) => { - if (path === '/api/features') { - const error = { response: { status: 404 } }; - throw error; - } + it('registers fatal error if features endpoint fails unexpectedly', async () => { + const error = { response: { status: 500 } }; + const getFeatures = jest.fn().mockRejectedValue(error); + const props = getProps({ action: 'edit' }); + const wrapper = mountWithIntl(); - if (path === '/api/spaces/space') { - return buildSpaces(); - } - }); + await waitForRender(wrapper); + expect(props.fatalErrors.add).toHaveBeenLastCalledWith(error); + expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(0); + }); - const wrapper = mountWithIntl(); + it('can render if features call is not allowed', async () => { + const error = { response: { status: 403 } }; + const getFeatures = jest.fn().mockRejectedValue(error); + const props = getProps({ action: 'edit' }); + const wrapper = mountWithIntl(); await waitForRender(wrapper); - + expect(props.fatalErrors.add).not.toHaveBeenCalled(); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); expectSaveFormButtons(wrapper); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index 5d6b4a1b4fdaf..f810cd2079d16 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -256,13 +256,12 @@ function useFeatures( // possible that a user with `manage_security` will attempt to visit the role management page without the // correct Kibana privileges. If that's the case, then they receive a partial view of the role, and the UI does // not allow them to make changes to that role's kibana privileges. When this user visits the edit role page, - // this API endpoint will throw a 404, which causes view to fail completely. So we instead attempt to detect the - // 404 here, and respond in a way that still allows the UI to render itself. - const unauthorizedForFeatures = err.response?.status === 404; + // this API endpoint will throw a 403, which causes view to fail completely. So we instead attempt to detect the + // 403 here, and respond in a way that still allows the UI to render itself. + const unauthorizedForFeatures = err.response?.status === 403; if (unauthorizedForFeatures) { return [] as KibanaFeature[]; } - fatalErrors.add(err); }) .then((retrievedFeatures) => { @@ -296,7 +295,6 @@ export const EditRolePage: FunctionComponent = ({ // We should keep the same mutable instance of Validator for every re-render since we'll // eventually enable validation after the first time user tries to save a role. const { current: validator } = useRef(new RoleValidator({ shouldValidate: false })); - const [formError, setFormError] = useState(null); const runAsUsers = useRunAsUsers(userAPIClient, fatalErrors); const indexPatternsTitles = useIndexPatternsTitles(indexPatterns, fatalErrors, notifications); diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts index a59951f5fcfe2..813e23a13ff37 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts @@ -53,26 +53,49 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens schema: { auditLoggingEnabled: { type: 'boolean', + _meta: { + description: + 'Indicates if audit logging is both enabled and supported by the current license.', + }, }, loginSelectorEnabled: { type: 'boolean', + _meta: { + description: 'Indicates if the login selector UI is enabled.', + }, }, accessAgreementEnabled: { type: 'boolean', + _meta: { + description: + 'Indicates if the access agreement UI is both enabled and supported by the current license.', + }, }, authProviderCount: { type: 'long', + _meta: { + description: + 'The number of configured auth providers (including disabled auth providers).', + }, }, enabledAuthProviders: { type: 'array', items: { type: 'keyword', + _meta: { + description: + 'The types of enabled auth providers (such as `saml`, `basic`, `pki`, etc).', + }, }, }, httpAuthSchemes: { type: 'array', items: { type: 'keyword', + _meta: { + description: + 'The set of enabled http auth schemes. Used for api-based usage, and when credentials are provided via reverse-proxy.', + }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 86f854fd0a145..1efcdf2d792f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -14,6 +14,8 @@ import { EuiTableActionsColumnType, EuiTableComputedColumnType, EuiTableFieldDataColumnType, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; @@ -73,12 +75,12 @@ export const getCasesColumns = ( return theCase.status !== CaseStatuses.closed ? ( caseDetailsLinkComponent ) : ( - <> - {caseDetailsLinkComponent} - + + {caseDetailsLinkComponent} + {i18n.CLOSED} - - + + ); } return getEmptyTagValue(); @@ -132,7 +134,6 @@ export const getCasesColumns = ( align: RIGHT_ALIGNMENT, field: 'totalAlerts', name: ALERTS, - sortable: true, render: (totalAlerts: Case['totalAlerts']) => totalAlerts != null ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) @@ -142,22 +143,21 @@ export const getCasesColumns = ( align: RIGHT_ALIGNMENT, field: 'totalComment', name: i18n.COMMENTS, - sortable: true, render: (totalComment: Case['totalComment']) => totalComment != null ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) : getEmptyTagValue(), }, - filterStatus === CaseStatuses.open + filterStatus === CaseStatuses.closed ? { - field: 'createdAt', - name: i18n.OPENED_ON, + field: 'closedAt', + name: i18n.CLOSED_ON, sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { return ( - - + + ); } @@ -165,14 +165,14 @@ export const getCasesColumns = ( }, } : { - field: 'closedAt', - name: i18n.CLOSED_ON, + field: 'createdAt', + name: i18n.OPENED_ON, sortable: true, - render: (closedAt: Case['closedAt']) => { - if (closedAt != null) { + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { return ( - - + + ); } diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 3ac0084e96fb3..c7dd392bf801c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -827,7 +827,7 @@ describe('AllCases', () => { wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click'); await waitFor(() => { expect(setQueryParams).toBeCalledWith({ - sortField: 'updatedAt', + sortField: 'createdAt', }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index c5748a321c19b..9f3e23fcde1c0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -342,7 +342,7 @@ export const AllCases = React.memo( newFilterOptions.status && newFilterOptions.status === CaseStatuses['in-progress'] ) { - setQueryParams({ sortField: SortFieldCase.updatedAt }); + setQueryParams({ sortField: SortFieldCase.createdAt }); } setFilters(newFilterOptions); refreshCases(false); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx index 1f1876756773d..ac0bb1f1c742f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx @@ -41,6 +41,7 @@ describe('ConnectorsDropdown', () => { "inputDisplay": { "inputDisplay": { "inputDisplay": { "inputDisplay": { "inputDisplay": + @@ -77,7 +77,7 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - + = ({ {' '} - + {i18n.FIELD_MAPPING_FIRST_COL} diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx index a732f403ee646..07f5fe35bc834 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx @@ -28,9 +28,9 @@ const FieldMappingRowComponent: React.FC = ({ selectedActionType, ]); return ( - + - + {securitySolutionField} @@ -40,7 +40,7 @@ const FieldMappingRowComponent: React.FC = ({ - + {isLoading ? ( diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index c99cabb50e3dc..40a202f5257a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -341,7 +341,7 @@ describe('AddToCaseAction', () => { ); expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('disabled') + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') ).toBeTruthy(); }); @@ -358,7 +358,7 @@ describe('AddToCaseAction', () => { ); expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('disabled') + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index decd37a7646e7..45c1355cecfa7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -172,7 +172,7 @@ const AddToCaseActionComponent: React.FC = ({ size="s" iconType="folderClosed" onClick={openPopover} - disabled={isDisabled} + isDisabled={isDisabled} /> ), diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 6feb5a1501a76..ac60f2999c510 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -117,7 +117,6 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', closedAt = 'closedAt', - updatedAt = 'updatedAt', } export interface ElasticUser { diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.test.tsx new file mode 100644 index 0000000000000..a1547940765c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.test.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { repeat } from 'lodash/fp'; +import React from 'react'; + +import { LineClamp } from '.'; + +describe('LineClamp', () => { + const message = repeat(1000, 'abcdefghij '); // 10 characters, with a trailing space + + describe('no overflow', () => { + test('it does NOT render the expanded line clamp when isOverflow is falsy', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="expanded-line-clamp"]').exists()).toBe(false); + }); + + test('it does NOT render the styled line clamp expanded when isOverflow is falsy', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="styled-line-clamp"]').exists()).toBe(false); + }); + + test('it renders the default line clamp when isOverflow is falsy', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="default-line-clamp"]').first().text()).toBe(message); + }); + + test('it does NOT render the `Read More` button when isOverflow is falsy', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="summary-view-readmore"]').exists()).toBe(false); + }); + }); + + describe('overflow', () => { + const clientHeight = 400; + const scrollHeight = clientHeight + 100; // scrollHeight is > clientHeight + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: clientHeight, + }); + + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: scrollHeight, + }); + }); + + test('it does NOT render the expanded line clamp by default when isOverflow is true', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="expanded-line-clamp"]').exists()).toBe(false); + }); + + test('it renders the styled line clamp when isOverflow is true', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="styled-line-clamp"]').first().text()).toBe(message); + }); + + test('it does NOT render the default line clamp when isOverflow is true', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="default-line-clamp"]').exists()).toBe(false); + }); + + test('it renders the `Read More` button with the expected (default) text when isOverflow is true', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="summary-view-readmore"]').first().text()).toBe( + 'Read More' + ); + }); + + describe('clicking the Read More button', () => { + test('it displays the `Read Less` button text after the user clicks the `Read More` button when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="summary-view-readmore"]').first().text()).toBe( + 'Read Less' + ); + }); + + test('it renders the expanded content after the user clicks the `Read More` button when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="expanded-line-clamp"]').first().text()).toBe(message); + }); + }); + + test('it renders the expanded content with a max-height of one third the view height when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="expanded-line-clamp"]').first()).toHaveStyleRule( + 'max-height', + '33vh' + ); + }); + + test('it automatically vertically scrolls the content when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="expanded-line-clamp"]').first()).toHaveStyleRule( + 'overflow-y', + 'auto' + ); + }); + + test('it does NOT render the styled line clamp after the user clicks the `Read More` button when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="styled-line-clamp"]').exists()).toBe(false); + }); + + test('it does NOT render the default line clamp after the user clicks the `Read More` button when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="default-line-clamp"]').exists()).toBe(false); + }); + + test('it once again displays the `Read More` button text after the user clicks the `Read Less` when isOverflow is true', () => { + const wrapper = mount(); + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); // 1st toggle + + wrapper.find('[data-test-subj="summary-view-readmore"]').first().simulate('click'); + wrapper.update(); // 2nd toggle + + expect(wrapper.find('[data-test-subj="summary-view-readmore"]').first().text()).toBe( + 'Read More' // after the 2nd toggle, the button once-again says `Read More` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx index d3bc4c2d50f98..896b0ec5fd8df 100644 --- a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import * as i18n from './translations'; const LINE_CLAMP = 3; -const LINE_CLAMP_HEIGHT = 4.5; +const LINE_CLAMP_HEIGHT = 5.5; const StyledLineClamp = styled.div` display: -webkit-box; @@ -28,6 +28,13 @@ const ReadMore = styled(EuiButtonEmpty)` } `; +const ExpandedContent = styled.div` + max-height: 33vh; + overflow-wrap: break-word; + overflow-x: hidden; + overflow-y: auto; +`; + const LineClampComponent: React.FC<{ content?: string | null }> = ({ content }) => { const [isOverflow, setIsOverflow] = useState(null); const [isExpanded, setIsExpanded] = useState(null); @@ -60,11 +67,15 @@ const LineClampComponent: React.FC<{ content?: string | null }> = ({ content }) return ( <> {isExpanded ? ( -

{content}

+ +

{content}

+
) : isOverflow == null || isOverflow === true ? ( - {content} + + {content} + ) : ( - {content} + {content} )} {isOverflow && ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index b2e5638ff120e..26b9662a8f19b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -215,19 +215,20 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const openAlertActionComponent = ( - - {i18n.ACTION_OPEN_ALERT} - - ); + const openAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_OPEN_ALERT} + + ); + }, [openAlertActionOnClick, hasIndexUpdateDelete, hasIndexMaintenance]); const closeAlertActionClick = useCallback(() => { updateAlertStatusAction({ @@ -248,19 +249,20 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const closeAlertActionComponent = ( - - {i18n.ACTION_CLOSE_ALERT} - - ); + const closeAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_CLOSE_ALERT} + + ); + }, [closeAlertActionClick, hasIndexUpdateDelete, hasIndexMaintenance]); const inProgressAlertActionClick = useCallback(() => { updateAlertStatusAction({ @@ -281,72 +283,77 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const inProgressAlertActionComponent = ( - - {i18n.ACTION_IN_PROGRESS_ALERT} - - ); - - const button = ( - - - - ); + const inProgressAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_IN_PROGRESS_ALERT} + + ); + }, [canUserCRUD, hasIndexUpdateDelete, inProgressAlertActionClick]); + + const button = useMemo(() => { + return ( + + + + ); + }, [disabled, onButtonClick, ariaLabel]); const handleAddEndpointExceptionClick = useCallback((): void => { closePopover(); setOpenAddExceptionModal('endpoint'); }, [closePopover]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const addEndpointExceptionComponent = ( - - {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} - - ); + const addEndpointExceptionComponent = useMemo(() => { + return ( + + {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} + + ); + }, [canUserCRUD, hasIndexWrite, isEndpointAlert, handleAddEndpointExceptionClick]); const handleAddExceptionClick = useCallback((): void => { closePopover(); setOpenAddExceptionModal('detection'); }, [closePopover]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const addExceptionComponent = ( - - - {i18n.ACTION_ADD_EXCEPTION} - - - ); + const addExceptionComponent = useMemo(() => { + return ( + + + {i18n.ACTION_ADD_EXCEPTION} + + + ); + }, [handleAddExceptionClick, canUserCRUD, hasIndexWrite]); const statusFilters = useMemo(() => { if (!alertStatus) { diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap index 8db9006da6156..1b91d396bee30 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap @@ -27,16 +27,16 @@ exports[`ToolTipFilter renders correctly against snapshot 1`] = ` aria-label="Next" color="text" data-test-subj="previous-feature-button" - disabled={true} iconType="arrowLeft" + isDisabled={true} onClick={[MockFunction]} />
diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx index a74022a222528..cc7662cf1e960 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx @@ -42,7 +42,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(true); }); @@ -70,9 +70,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - false - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(false); }); test('nextFeature is called when featureIndex is < totalFeatures', () => { @@ -102,7 +102,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(false); }); @@ -130,9 +130,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - true - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(true); }); test('nextFunction is not called when featureIndex >== totalFeatures', () => { @@ -161,7 +161,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(true); }); @@ -189,9 +189,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - true - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(true); }); test('nextFunction is not called when only a single feature is provided', () => { @@ -221,7 +221,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(false); }); @@ -249,9 +249,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - false - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(false); }); test('nextFunction is called when featureIndex > 0 && featureIndex < totalFeatures', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx index 252260b2c5a2b..dbb280228e504 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx @@ -54,7 +54,7 @@ export const ToolTipFooterComponent = ({ onClick={previousFeature} iconType="arrowLeft" aria-label="Next" - disabled={featureIndex <= 0} + isDisabled={featureIndex <= 0} /> = totalFeatures - 1} + isDisabled={featureIndex >= totalFeatures - 1} />
diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx index f8913148c625b..84406aed3619f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx @@ -35,7 +35,8 @@ jest.mock('../../../../common/components/inspect', () => ({ InspectButtonContainer: jest.fn(({ children }) =>
{children}
), })); -describe('AddTimelineButton', () => { +// FLAKY: https://github.com/elastic/kibana/issues/96691 +describe.skip('AddTimelineButton', () => { let wrapper: ReactWrapper; const props = { timelineId: TimelineId.active, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index c5cbbeb09ed6d..ef7236084508d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -27,12 +27,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index dd636d5a180d9..d6693dc1f7a0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -29,12 +29,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 0a265adf620ee..b0b4232651803 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -34,7 +34,7 @@ describe('import_rules_route', () => { let server: ReturnType; let request: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); @@ -42,7 +42,7 @@ describe('import_rules_route', () => { config = createMockConfig(); const hapiStream = buildHapiStream(ruleIdsToNdJsonString(['rule-1'])); request = getImportRulesRequest(hapiStream); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 88250fb920d6c..93fdf9c5f8194 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -24,12 +24,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('patch_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 1f21a11f22ef5..6e62f65f44858 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -26,12 +26,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('patch_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 09ac156c375ee..41b31b04e3424 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -26,12 +26,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('update_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); clients.alertsClient.update.mockResolvedValue(getResult()); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index e5bea42bc49a1..c80d32e09ccab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -28,12 +28,12 @@ jest.mock('../../rules/update_rules_notifications'); describe('update_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts index b41ba543675ec..d87c53ecfba71 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts @@ -16,7 +16,7 @@ jest.mock('../../../common/machine_learning/has_ml_admin_permissions'); describe('isMlAdmin', () => { it('returns true if hasMlAdminPermissions is true', async () => { - const mockMl = mlServicesMock.create(); + const mockMl = mlServicesMock.createSetupContract(); const request = httpServerMock.createKibanaRequest(); const savedObjectsClient = savedObjectsClientMock.create(); (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); @@ -25,7 +25,7 @@ describe('isMlAdmin', () => { }); it('returns false if hasMlAdminPermissions is false', async () => { - const mockMl = mlServicesMock.create(); + const mockMl = mlServicesMock.createSetupContract(); const request = httpServerMock.createKibanaRequest(); const savedObjectsClient = savedObjectsClientMock.create(); (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); @@ -56,13 +56,13 @@ describe('hasMlLicense', () => { describe('mlAuthz', () => { let licenseMock: ReturnType; - let mlMock: ReturnType; + let mlMock: ReturnType; let request: KibanaRequest; let savedObjectsClient: SavedObjectsClientContract; beforeEach(() => { licenseMock = licensingMock.createLicenseMock(); - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); request = httpServerMock.createKibanaRequest(); savedObjectsClient = savedObjectsClientMock.create(); }); diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts index 5d1b090e98a79..a121a682d2892 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts @@ -5,25 +5,9 @@ * 2.0. */ -import { MlPluginSetup } from '../../../../ml/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; +import { mlPluginServerMock } from '../../../../ml/server/mocks'; -const createMockClient = () => elasticsearchServiceMock.createLegacyClusterClient(); -const createMockMlSystemProvider = () => - jest.fn(() => ({ - mlCapabilities: jest.fn(), - })); - -export const mlServicesMock = { - create: () => - (({ - modulesProvider: jest.fn(), - jobServiceProvider: jest.fn(), - anomalyDetectorsProvider: jest.fn(), - mlSystemProvider: createMockMlSystemProvider(), - mlClient: createMockClient(), - } as unknown) as jest.Mocked), -}; +export const mlServicesMock = mlPluginServerMock; const mockValidateRuleType = jest.fn().mockResolvedValue({ valid: true, message: undefined }); const createBuildMlAuthzMock = () => diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index b53f90f40f621..64a33068ad686 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -21,12 +21,12 @@ import { fetchDetectionsUsage, fetchDetectionsMetrics } from './index'; describe('Detections Usage and Metrics', () => { let esClientMock: jest.Mocked; let savedObjectsClientMock: jest.Mocked; - let mlMock: ReturnType; + let mlMock: ReturnType; describe('fetchDetectionsUsage()', () => { beforeEach(() => { esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); }); it('returns zeroed counts if both calls are empty', async () => { @@ -108,7 +108,7 @@ describe('Detections Usage and Metrics', () => { describe('fetchDetectionsMetrics()', () => { beforeEach(() => { - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); }); it('returns an empty array if there is no data', async () => { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.test.tsx index e0326a6c9ff11..cb821061b9251 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.test.tsx @@ -112,7 +112,8 @@ const setup = async (opts: SetupOpts = {}) => { return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToCopy }; }; -describe('CopyToSpaceFlyout', () => { +// flaky https://github.com/elastic/kibana/issues/96708 +describe.skip('CopyToSpaceFlyout', () => { it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 9f91398cc7d24..e168ec21438c0 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -15,7 +15,7 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export const BUILT_IN_ALERTS_FEATURE = { id: STACK_ALERTS_FEATURE_ID, name: i18n.translate('xpack.stackAlerts.featureRegistry.actionsFeatureName', { - defaultMessage: 'Stack Alerts', + defaultMessage: 'Stack Rules', }), app: [], category: DEFAULT_APP_CATEGORIES.management, diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e1e0711c2bb2c..3d302aa12832e 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3896,27 +3896,45 @@ "security": { "properties": { "auditLoggingEnabled": { - "type": "boolean" + "type": "boolean", + "_meta": { + "description": "Indicates if audit logging is both enabled and supported by the current license." + } }, "loginSelectorEnabled": { - "type": "boolean" + "type": "boolean", + "_meta": { + "description": "Indicates if the login selector UI is enabled." + } }, "accessAgreementEnabled": { - "type": "boolean" + "type": "boolean", + "_meta": { + "description": "Indicates if the access agreement UI is both enabled and supported by the current license." + } }, "authProviderCount": { - "type": "long" + "type": "long", + "_meta": { + "description": "The number of configured auth providers (including disabled auth providers)." + } }, "enabledAuthProviders": { "type": "array", "items": { - "type": "keyword" + "type": "keyword", + "_meta": { + "description": "The types of enabled auth providers (such as `saml`, `basic`, `pki`, etc)." + } } }, "httpAuthSchemes": { "type": "array", "items": { - "type": "keyword" + "type": "keyword", + "_meta": { + "description": "The set of enabled http auth schemes. Used for api-based usage, and when credentials are provided via reverse-proxy." + } } } } diff --git a/x-pack/plugins/telemetry_collection_xpack/server/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/index.ts index d924882e17fbd..aab1bdb58fe59 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/index.ts @@ -7,7 +7,7 @@ import { TelemetryCollectionXpackPlugin } from './plugin'; -export { ESLicense } from './telemetry_collection'; +export type { ESLicense } from './telemetry_collection'; // This exports static code and TypeScript types, // as well as, Kibana Platform `plugin()` initializer. diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts index 4599b068b9b38..c1a11caf44f24 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { ESLicense } from './get_license'; +export type { ESLicense } from './get_license'; export { getStatsWithXpack } from './get_stats_with_xpack'; diff --git a/x-pack/plugins/telemetry_collection_xpack/tsconfig.json b/x-pack/plugins/telemetry_collection_xpack/tsconfig.json index 476f5926f757a..1221200c7548c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/tsconfig.json +++ b/x-pack/plugins/telemetry_collection_xpack/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "common/**/*", diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 277226c81c925..7965db99b335b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -91,7 +91,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = defaultMessage: 'Runtime mappings', })} > - + {runtimeMappings !== undefined && Object.keys(runtimeMappings).length > 0 ? ( = React.memo((props) => { } > <> - + {/* Flex Column #1: Search Bar / Advanced Search Editor */} {searchItems.savedSearch === undefined && ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0c16860acf56c..fc8ca5204e01a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -541,7 +541,6 @@ "core.ui_settings.params.notifications.warningLifetimeTitle": "警告通知時間", "core.ui_settings.params.storeUrlText": "URLが長くなりすぎるためブラウザーが対応できない場合があります。セッションストレージにURLの一部を保存することでこの問題に対処できるかどうかをテストしています。結果を教えてください!", "core.ui_settings.params.storeUrlTitle": "セッションストレージにURLを格納", - "core.ui_settings.params.themeVersionText": "現在のバージョンと次のバージョンのKibanaで使用されるテーマを切り替えます。この設定を適用するにはページの更新が必要です。", "core.ui_settings.params.themeVersionTitle": "テーマバージョン", "core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "Elastic ホーム", "core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "Elastic に確認する", @@ -746,10 +745,6 @@ "data.advancedSettings.format.percentFormat.numeralFormatLinkText": "数字フォーマット", "data.advancedSettings.format.percentFormatText": "「パーセント」フォーマットのデフォルト{numeralFormatLink}です", "data.advancedSettings.format.percentFormatTitle": "パーセントフォーマット", - "data.advancedSettings.histogram.barTargetText": "日付ヒストグラムで「自動」間隔を使用する際、この数に近いバーの作成を試みます", - "data.advancedSettings.histogram.barTargetTitle": "目標バー数", - "data.advancedSettings.histogram.maxBarsText": "日付ヒストグラムに表示されるバーの数の上限です。必要に応じて値をスケーリングしてください", - "data.advancedSettings.histogram.maxBarsTitle": "最高バー数", "data.advancedSettings.historyLimitText": "履歴があるフィールド (例:クエリインプット) に個の数の最近の値が表示されます", "data.advancedSettings.historyLimitTitle": "履歴制限数", "data.advancedSettings.indexPatternPlaceholderText": "「管理 > インデックスパターン > インデックスパターンを作成」で使用される「インデックスパターン名」フィールドのプレースホルダーです。", @@ -4015,8 +4010,6 @@ "visTypeTimeseries.addDeleteButtons.deleteButtonDefaultTooltip": "削除", "visTypeTimeseries.addDeleteButtons.reEnableTooltip": "再度有効にする", "visTypeTimeseries.addDeleteButtons.temporarilyDisableTooltip": "一時的に無効にする", - "visTypeTimeseries.advancedSettings.maxBucketsText": "1つのデータソースが返せるバケットの最大数です", - "visTypeTimeseries.advancedSettings.maxBucketsTitle": "バケットの最大数", "visTypeTimeseries.aggLookup.averageLabel": "平均", "visTypeTimeseries.aggLookup.calculationLabel": "計算", "visTypeTimeseries.aggLookup.cardinalityLabel": "基数", @@ -13725,7 +13718,8 @@ "xpack.ml.editModelSnapshotFlyout.useDefaultButton": "削除", "xpack.ml.explorer.addToDashboard.cancelButtonLabel": "キャンセル", "xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "ダッシュボードを選択:", - "xpack.ml.explorer.addToDashboard.selectSwimlanesLabel": "スイムレーンビューを選択:", + "xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "スイムレーンをダッシュボードに追加", + "xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "スイムレーンビューを選択:", "xpack.ml.explorer.addToDashboardLabel": "ダッシュボードに追加", "xpack.ml.explorer.annotationsErrorCallOutTitle": "注釈の読み込み中にエラーが発生しました。", "xpack.ml.explorer.annotationsErrorTitle": "注釈", @@ -13750,7 +13744,6 @@ "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "説明", "xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "ダッシュボード「{dashboardTitle}」は正常に更新されました", "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "タイトル", - "xpack.ml.explorer.dashboardsTitle": "スイムレーンをダッシュボードに追加", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "異常スコア", "xpack.ml.explorer.distributionChart.entityLabel": "エンティティ", "xpack.ml.explorer.distributionChart.typicalLabel": "通常", @@ -16761,10 +16754,6 @@ "xpack.remoteClusters.addTitle": "リモートクラスターを追加", "xpack.remoteClusters.appName": "リモートクラスター", "xpack.remoteClusters.appTitle": "リモートクラスター", - "xpack.remoteClusters.cloudClusterInformationDescription": "クラスターのプロキシアドレスとサーバー名を見つけるには、デプロイメニューの{security}ページに移動し、{searchString}を検索します。", - "xpack.remoteClusters.cloudClusterInformationTitle": "Elasticsearch Cloudデプロイのプロキシモードを使用", - "xpack.remoteClusters.cloudClusterSearchDescription": "リモートクラスターパラメーター", - "xpack.remoteClusters.cloudClusterSecurityDescription": "セキュリティ", "xpack.remoteClusters.configuredByNodeWarningTitle": "このリモートクラスターはノードの elasticsearch.yml 構成ファイルで定義されているため、編集または削除できません。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "接続済み", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未接続", @@ -16838,7 +16827,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "サーバー名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "リモートクラスターごとに開くソケット接続の数。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "リクエストを非表示", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "「シードノード」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "「名前」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "「プロキシアドレス」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "「シードノード」フィールドが無効です。", @@ -22903,7 +22891,6 @@ "xpack.uptime.certs.status.ok.label": " {okRelativeDate}", "xpack.uptime.charts.mlAnnotation.header": "スコア:{score}", "xpack.uptime.charts.mlAnnotation.severity": "深刻度:{severity}", - "xpack.uptime.components.embeddables.embeddedMap.embeddablePanelTitle": "オブザーバー位置情報マップを監視", "xpack.uptime.controls.selectSeverity.criticalLabel": "致命的", "xpack.uptime.controls.selectSeverity.majorLabel": "メジャー", "xpack.uptime.controls.selectSeverity.minorLabel": "マイナー", @@ -22933,12 +22920,7 @@ "xpack.uptime.filterPopout.searchMessage.ariaLabel": "{title} を検索", "xpack.uptime.filterPopover.filterItem.label": "{title} {item}でフィルタリングします。", "xpack.uptime.integrationLink.missingDataMessage": "この統合に必要なデータが見つかりませんでした。", - "xpack.uptime.locationAvailabilityViewToggleLegend": "トグルを表示", - "xpack.uptime.locationMap.locations.missing.message": "重要な位置情報構成がありません。{codeBlock}フィールドを使用して、アップタイムチェック用に一意の地域を作成できます。", - "xpack.uptime.locationMap.locations.missing.message1": "詳細については、ドキュメンテーションを参照してください。", - "xpack.uptime.locationMap.locations.missing.title": "地理情報の欠測", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", - "xpack.uptime.mapToolTip.AvailabilityStat.title": "{value} %", "xpack.uptime.ml.durationChart.exploreInMlApp": "ML アプリで探索", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "異常検知", "xpack.uptime.ml.enableAnomalyDetectionPanel.cancelLabel": "キャンセル", @@ -23590,4 +23572,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5e5f53356a2e8..8aa8b95b01c1e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -544,7 +544,6 @@ "core.ui_settings.params.notifications.warningLifetimeTitle": "警告通知生存时间", "core.ui_settings.params.storeUrlText": "有时,URL 可能会变得过长,使某些浏览器无法进行处理。为此,我们将正测试在会话存储中存储 URL 的组成部分是否会有所帮助。请向我们反馈您的体验!", "core.ui_settings.params.storeUrlTitle": "将 URL 存储在会话存储中", - "core.ui_settings.params.themeVersionText": "在用于当前版和下一版 Kibana 的主题之间切换。需要刷新页面,才能应用设置。", "core.ui_settings.params.themeVersionTitle": "主题版本", "core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "Elastic 主页", "core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "问询 Elastic", @@ -749,10 +748,6 @@ "data.advancedSettings.format.percentFormat.numeralFormatLinkText": "数值格式", "data.advancedSettings.format.percentFormatText": "“百分比”格式的默认{numeralFormatLink}", "data.advancedSettings.format.percentFormatTitle": "百分比格式", - "data.advancedSettings.histogram.barTargetText": "在日期直方图中使用“auto”时尝试生成大约此数目的条形", - "data.advancedSettings.histogram.barTargetTitle": "目标条形数", - "data.advancedSettings.histogram.maxBarsText": "在日期直方图中不要显示超过该数目的条形,需要时显示刻度值", - "data.advancedSettings.histogram.maxBarsTitle": "最大条形数", "data.advancedSettings.historyLimitText": "在具有历史记录 (例如查询输入) 的字段中,显示此数目的最近值", "data.advancedSettings.historyLimitTitle": "历史记录限制", "data.advancedSettings.indexPatternPlaceholderText": "在“管理”>“索引模式”>“创建索引模式”中“索引模式名称”字段的占位符。", @@ -4043,8 +4038,6 @@ "visTypeTimeseries.addDeleteButtons.deleteButtonDefaultTooltip": "删除", "visTypeTimeseries.addDeleteButtons.reEnableTooltip": "重新启用", "visTypeTimeseries.addDeleteButtons.temporarilyDisableTooltip": "暂时禁用", - "visTypeTimeseries.advancedSettings.maxBucketsText": "单个数据源可以返回的最大存储桶数目", - "visTypeTimeseries.advancedSettings.maxBucketsTitle": "最大存储桶数", "visTypeTimeseries.aggLookup.averageLabel": "平均值", "visTypeTimeseries.aggLookup.calculationLabel": "计算", "visTypeTimeseries.aggLookup.cardinalityLabel": "基数", @@ -13905,7 +13898,8 @@ "xpack.ml.editModelSnapshotFlyout.useDefaultButton": "删除", "xpack.ml.explorer.addToDashboard.cancelButtonLabel": "取消", "xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "选择仪表板:", - "xpack.ml.explorer.addToDashboard.selectSwimlanesLabel": "选择泳道视图:", + "xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "将泳道添加到仪表板", + "xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "选择泳道视图:", "xpack.ml.explorer.addToDashboardLabel": "添加到仪表板", "xpack.ml.explorer.annotationsErrorCallOutTitle": "加载注释时发生错误:", "xpack.ml.explorer.annotationsErrorTitle": "标注", @@ -13930,7 +13924,6 @@ "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "描述", "xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "仪表板“{dashboardTitle}”已成功更新", "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "标题", - "xpack.ml.explorer.dashboardsTitle": "将泳道添加到仪表板", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "异常分数", "xpack.ml.explorer.distributionChart.entityLabel": "实体", "xpack.ml.explorer.distributionChart.typicalLabel": "典型", @@ -16987,10 +16980,6 @@ "xpack.remoteClusters.addTitle": "添加远程集群", "xpack.remoteClusters.appName": "远程集群", "xpack.remoteClusters.appTitle": "远程集群", - "xpack.remoteClusters.cloudClusterInformationDescription": "要查找您的集群的代理地址和服务器名称,请前往部署菜单的{security}页面并搜索“{searchString}”。", - "xpack.remoteClusters.cloudClusterInformationTitle": "将代理模式用于 Elastic Cloud 部署", - "xpack.remoteClusters.cloudClusterSearchDescription": "远程集群参数", - "xpack.remoteClusters.cloudClusterSecurityDescription": "安全", "xpack.remoteClusters.configuredByNodeWarningTitle": "您无法编辑或删除此远程集群,因为它是在节点的 elasticsearch.yml 配置文件中定义的。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "已连接", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未连接", @@ -17065,7 +17054,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "服务器名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "每个远程集群要打开的套接字数目。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "隐藏请求", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "“种子节点”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "“名称”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "“代理地址”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "“种子节点”字段无效。", @@ -23262,7 +23250,6 @@ "xpack.uptime.certs.status.ok.label": " 对于 {okRelativeDate}", "xpack.uptime.charts.mlAnnotation.header": "分数:{score}", "xpack.uptime.charts.mlAnnotation.severity": "严重性:{severity}", - "xpack.uptime.components.embeddables.embeddedMap.embeddablePanelTitle": "监测观察者位置地图", "xpack.uptime.controls.selectSeverity.criticalLabel": "紧急", "xpack.uptime.controls.selectSeverity.majorLabel": "重大", "xpack.uptime.controls.selectSeverity.minorLabel": "轻微", @@ -23292,12 +23279,7 @@ "xpack.uptime.filterPopout.searchMessage.ariaLabel": "搜索 {title}", "xpack.uptime.filterPopover.filterItem.label": "按 {title} {item} 筛选。", "xpack.uptime.integrationLink.missingDataMessage": "未找到此集成的所需数据。", - "xpack.uptime.locationAvailabilityViewToggleLegend": "视图切换", - "xpack.uptime.locationMap.locations.missing.message": "重要的地理位置配置缺失。您可以使用 {codeBlock} 字段为您的运行时间检查创建独特的地理区域。", - "xpack.uptime.locationMap.locations.missing.message1": "在我们的文档中获取更多的信息。", - "xpack.uptime.locationMap.locations.missing.title": "地理信息缺失", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", - "xpack.uptime.mapToolTip.AvailabilityStat.title": "{value} %", "xpack.uptime.ml.durationChart.exploreInMlApp": "在 ML 应用中浏览", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "异常检测", "xpack.uptime.ml.enableAnomalyDetectionPanel.cancelLabel": "取消", @@ -23959,4 +23941,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx index 8d27edd9e4bcc..4e03a2a09bed4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx @@ -117,4 +117,16 @@ describe('AddMessageVariables', () => { wrapper.find('button[data-test-subj="variableMenuButton-deprecatedVar"]').getDOMNode() ).toBeDisabled(); }); + + test(`it does't render when no variables exist`, () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="fooAddVariableButton"]')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx index bf89e4f6ae6e1..57b251fba0d45 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiPopover, @@ -61,13 +61,16 @@ export const AddMessageVariables: React.FunctionComponent = ({ } ); + if ((messageVariables?.length ?? 0) === 0) { + return ; + } + return ( setIsVariablesPopoverOpen(true)} iconType="indexOpen" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 00a029a9abb5a..9757653043175 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -82,32 +82,71 @@ describe('index connector validation with minimal config', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - documents: [{ test: 1234 }], - }; + test('action params validation succeeds when action params are valid', () => { + expect( + actionTypeModel.validateParams({ + documents: [{ test: 1234 }], + }) + ).toEqual({ + errors: { + documents: [], + indexOverride: [], + }, + }); - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect( + actionTypeModel.validateParams({ + documents: [{ test: 1234 }], + indexOverride: 'kibana-alert-history-anything', + }) + ).toEqual({ errors: { documents: [], + indexOverride: [], }, }); + }); - const emptyActionParams = {}; + test('action params validation fails when action params are invalid', () => { + expect(actionTypeModel.validateParams({})).toEqual({ + errors: { + documents: ['Document is required and should be a valid JSON object.'], + indexOverride: [], + }, + }); - expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ + expect( + actionTypeModel.validateParams({ + documents: [{}], + }) + ).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], + indexOverride: [], }, }); - const invalidDocumentActionParams = { - documents: [{}], - }; + expect( + actionTypeModel.validateParams({ + documents: [{}], + indexOverride: 'kibana-alert-history-', + }) + ).toEqual({ + errors: { + documents: ['Document is required and should be a valid JSON object.'], + indexOverride: ['Alert history index must contain valid suffix.'], + }, + }); - expect(actionTypeModel.validateParams(invalidDocumentActionParams)).toEqual({ + expect( + actionTypeModel.validateParams({ + documents: [{}], + indexOverride: 'this.is-a_string', + }) + ).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], + indexOverride: ['Alert history index must begin with "kibana-alert-history-".'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index bc09e5abe1120..f4b8284c8cfa6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -11,6 +11,7 @@ import { ActionTypeModel, GenericValidationResult, ConnectorValidationResult, + ALERT_HISTORY_PREFIX, } from '../../../../types'; import { EsIndexActionConnector, EsIndexConfig, IndexActionParams } from '../types'; @@ -56,6 +57,7 @@ export function getActionType(): ActionTypeModel => { const errors = { documents: new Array(), + indexOverride: new Array(), }; const validationResult = { errors }; if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { @@ -68,6 +70,32 @@ export function getActionType(): ActionTypeModel { - test('all params fields is rendered', () => { + test('all params fields are rendered correctly when params are undefined', () => { + const actionParams = { + documents: undefined, + }; + const wrapper = mountWithIntl( + {}} + index={0} + actionConnector={actionConnector} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} + /> + ); + expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(``); + expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="preconfiguredDocumentToIndex"]').length > 0).toBeFalsy(); + }); + + test('all params fields are rendered when document params are defined', () => { const actionParams = { documents: [{ test: 123 }], }; @@ -22,11 +73,90 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} + actionConnector={actionConnector} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ "test": 123 }`); expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="preconfiguredDocumentToIndex"]').length > 0).toBeFalsy(); + }); + + test('all params fields are rendered correctly for preconfigured alert history connector when params are undefined', () => { + const actionParams = { + documents: undefined, + }; + const wrapper = mountWithIntl( + {}} + index={0} + actionConnector={preconfiguredActionConnector} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} + /> + ); + expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').first().prop('value')).toBe( + 'default' + ); + expect(wrapper.find('[data-test-subj="preconfiguredDocumentToIndex"]').length > 0).toBeTruthy(); + }); + + test('all params fields are rendered correctly for preconfigured alert history connector when params are defined', async () => { + const actionParams = { + documents: undefined, + indexOverride: 'kibana-alert-history-not-the-default', + }; + const wrapper = mountWithIntl( + {}} + index={0} + actionConnector={preconfiguredActionConnector} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} + /> + ); + expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').first().prop('value')).toBe( + 'not-the-default' + ); + expect(wrapper.find('[data-test-subj="preconfiguredDocumentToIndex"]').length > 0).toBeTruthy(); + + wrapper.find('EuiLink[data-test-subj="resetDefaultIndex"]').simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="preconfiguredIndexToUse"]').first().prop('value')).toBe( + 'default' + ); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index c65c76ee6916e..6973cdcc7a088 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -5,11 +5,25 @@ * 2.0. */ -import React from 'react'; -import { EuiLink } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { + EuiIcon, + EuiText, + EuiCodeBlock, + EuiFieldText, + EuiFormRow, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ActionParamsProps } from '../../../../types'; +import { + ActionParamsProps, + AlertHistoryEsIndexConnectorId, + AlertHistoryDocumentTemplate, + AlertHistoryDefaultIndexName, + ALERT_HISTORY_PREFIX, +} from '../../../../types'; import { IndexActionParams } from '.././types'; import { JsonEditorWithMessageVariables } from '../../json_editor_with_message_variables'; import { useKibana } from '../../../../common/lib/kibana'; @@ -20,38 +34,152 @@ export const IndexParamsFields = ({ editAction, messageVariables, errors, + actionConnector, }: ActionParamsProps) => { const { docLinks } = useKibana().services; - const { documents } = actionParams; + const { documents, indexOverride } = actionParams; + + const defaultAlertHistoryIndexSuffix = AlertHistoryDefaultIndexName.replace( + ALERT_HISTORY_PREFIX, + '' + ); + + const getDocumentToIndex = (doc: Array> | undefined) => + doc && doc.length > 0 ? ((doc[0] as unknown) as string) : undefined; + + const [documentToIndex, setDocumentToIndex] = useState( + getDocumentToIndex(documents) + ); + const [alertHistoryIndexSuffix, setAlertHistoryIndexSuffix] = useState( + indexOverride ? indexOverride.replace(ALERT_HISTORY_PREFIX, '') : defaultAlertHistoryIndexSuffix + ); + const [usePreconfiguredSchema, setUsePreconfiguredSchema] = useState(false); + + useEffect(() => { + setDocumentToIndex(getDocumentToIndex(documents)); + }, [documents]); + + useEffect(() => { + if (actionConnector?.id === AlertHistoryEsIndexConnectorId) { + setUsePreconfiguredSchema(true); + editAction('documents', [JSON.stringify(AlertHistoryDocumentTemplate)], index); + setDocumentToIndex(JSON.stringify(AlertHistoryDocumentTemplate)); + } else { + setUsePreconfiguredSchema(false); + editAction('documents', undefined, index); + setDocumentToIndex(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector?.id]); const onDocumentsChange = (updatedDocuments: string) => { try { const documentsJSON = JSON.parse(updatedDocuments); editAction('documents', [documentsJSON], index); + setDocumentToIndex(updatedDocuments); } catch (e) { // set document as empty to turn on the validation for non empty valid JSON object editAction('documents', [{}], index); + setDocumentToIndex(undefined); } }; - return ( + const documentsFieldLabel = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', + { + defaultMessage: 'Document to index', + } + ); + + const resetDefaultIndex = + indexOverride && indexOverride !== AlertHistoryDefaultIndexName ? ( + + { + editAction('indexOverride', AlertHistoryDefaultIndexName, index); + setAlertHistoryIndexSuffix(defaultAlertHistoryIndexSuffix); + }} + > + + + + + ) : ( + <> + ); + + const preconfiguredDocumentSchema = ( + <> + 0} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex', + { + defaultMessage: 'Elasticsearch index', + } + )} + labelAppend={resetDefaultIndex} + helpText={ + <> + + + + + + } + > + { + editAction('indexOverride', `${ALERT_HISTORY_PREFIX}${e.target.value}`, index); + setAlertHistoryIndexSuffix(e.target.value); + }} + /> + + + + + {JSON.stringify(AlertHistoryDocumentTemplate, null, 2)} + + + + ); + + const jsonDocumentEditor = ( 0 - ? ((documents[0] as unknown) as string) - : undefined + : documentToIndex } - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', - { - defaultMessage: 'Document to index', - } - )} + label={documentsFieldLabel} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel', { @@ -69,15 +197,15 @@ export const IndexParamsFields = ({ } onBlur={() => { - if ( - !(documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined) - ) { + if (!documentToIndex) { // set document as empty to turn on the validation for non empty valid JSON object onDocumentsChange('{}'); } }} /> ); + + return usePreconfiguredSchema ? preconfiguredDocumentSchema : jsonDocumentEditor; }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index f3dbfc9c363cc..cae4221e5d7ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -18,6 +18,7 @@ import { PagerDutyConfig, PagerDutySecrets, PagerDutyActionParams, + EventActionOptions, } from '.././types'; import pagerDutySvg from './pagerduty.svg'; import { hasMustacheTokens } from '../../../lib/has_mustache_tokens'; @@ -88,7 +89,10 @@ export function getActionType(): ActionTypeModel< ) ); } - if (!actionParams.summary?.length) { + if ( + actionParams.eventAction === EventActionOptions.TRIGGER && + !actionParams.summary?.length + ) { errors.summary.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 6e3f4213b7907..f67267a75ed33 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -30,6 +30,13 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [], dedupKey: [] }} editAction={() => {}} index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); @@ -50,9 +57,29 @@ describe('PagerDutyParamsFields renders', () => { expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy(); }); - test('params select fields dont auto set values ', () => { + test('params select fields do not auto set values eventActionSelect', () => { const actionParams = {}; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="eventActionSelect"]').first().prop('value') + ).toStrictEqual(undefined); + }); + + test('params select fields do not auto set values severitySelect', () => { + const actionParams = { + eventAction: EventActionOptions.TRIGGER, + dedupKey: 'test', + }; + const wrapper = mountWithIntl( { expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( undefined ); + }); + + test('only eventActionSelect is available as a payload params for PagerDuty Resolve event', () => { + const actionParams = { + eventAction: EventActionOptions.RESOLVE, + dedupKey: 'test', + }; + + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').first().prop('value')).toStrictEqual( + 'test' + ); expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="eventActionSelect"]').first().prop('value') - ).toStrictEqual(undefined); + ).toStrictEqual('resolve'); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeFalsy(); + expect(wrapper.find('[data-test-subj="summaryInput"]').length > 0).toBeFalsy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 6923c8dac0001..98dd9c6bf8431 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -99,32 +99,11 @@ const PagerDutyParamsFields: React.FunctionComponent - - - { - editAction('severity', e.target.value, index); - }} - /> - - - - 0 && summary !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', - { - defaultMessage: 'Summary', - } - )} - > - - - - + + {isTriggerPagerDutyEvent ? ( + <> + 0 && timestamp !== undefined} + error={errors.summary} + isInvalid={errors.summary.length > 0 && summary !== undefined} label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { - defaultMessage: 'Timestamp (optional)', + defaultMessage: 'Summary', } )} > @@ -218,83 +178,130 @@ const PagerDutyParamsFields: React.FunctionComponent - - - - - - - - - - - - - - - + + + + + { + editAction('severity', e.target.value, index); + }} + /> + + + + 0 && timestamp !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', + { + defaultMessage: 'Timestamp (optional)', + } + )} + > + + + + + + + + + + + + + + + + + + + ) : null} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index 8a1b2bfb4ac22..d94cdde349dc7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -42,6 +42,7 @@ export interface PagerDutyActionParams { export interface IndexActionParams { documents: Array>; + indexOverride?: string; } export enum ServerLogLevelOptions { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 801d9a6b43ec6..a3756ae74fd14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -21,6 +21,13 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts index 435e4c5637ee5..1414242358d58 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts @@ -32,6 +32,10 @@ describe('transformActionVariables', () => { "description": "The tags of the rule.", "name": "rule.tags", }, + Object { + "description": "The type of rule.", + "name": "rule.type", + }, Object { "description": "The date the rule scheduled the action.", "name": "date", @@ -127,6 +131,10 @@ describe('transformActionVariables', () => { "description": "The tags of the rule.", "name": "rule.tags", }, + Object { + "description": "The type of rule.", + "name": "rule.type", + }, Object { "description": "The date the rule scheduled the action.", "name": "date", @@ -230,6 +238,10 @@ describe('transformActionVariables', () => { "description": "The tags of the rule.", "name": "rule.tags", }, + Object { + "description": "The type of rule.", + "name": "rule.type", + }, Object { "description": "The date the rule scheduled the action.", "name": "date", @@ -336,6 +348,10 @@ describe('transformActionVariables', () => { "description": "The tags of the rule.", "name": "rule.tags", }, + Object { + "description": "The type of rule.", + "name": "rule.type", + }, Object { "description": "The date the rule scheduled the action.", "name": "date", @@ -460,6 +476,10 @@ describe('transformActionVariables', () => { "description": "The tags of the rule.", "name": "rule.tags", }, + Object { + "description": "The type of rule.", + "name": "rule.type", + }, Object { "description": "The date the rule scheduled the action.", "name": "date", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts index 29f2b277c97a3..9722cc42ed396 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts @@ -26,6 +26,7 @@ export enum AlertProvidedActionVariables { ruleName = 'rule.name', ruleSpaceId = 'rule.spaceId', ruleTags = 'rule.tags', + ruleType = 'rule.type', date = 'date', alertId = 'alert.id', alertActionGroup = 'alert.actionGroup', @@ -83,6 +84,13 @@ function getAlwaysProvidedActionVariables(): ActionVariable[] { }), }); + result.push({ + name: AlertProvidedActionVariables.ruleType, + description: i18n.translate('xpack.triggersActionsUI.actionVariables.ruleTypeLabel', { + defaultMessage: 'The type of rule.', + }), + }); + result.push({ name: AlertProvidedActionVariables.date, description: i18n.translate('xpack.triggersActionsUI.actionVariables.dateLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 7fbe68776ca28..48c6c1b42d7af 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -105,6 +105,7 @@ export const ActionTypeForm = ({ const defaultActionGroup = actionGroups?.find(({ id }) => id === defaultActionGroupId); const selectedActionGroup = actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; + const [actionGroup, setActionGroup] = useState(); useEffect(() => { setAvailableActionVariables( @@ -120,6 +121,15 @@ export const ActionTypeForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionItem.group]); + useEffect(() => { + if (defaultParams && actionGroup) { + for (const [key, paramValue] of Object.entries(defaultParams)) { + setActionParamsProperty(key, paramValue, index); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionGroup]); + const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { const selectedConnector = connectors.find((connector) => connector.id === actionItemId); @@ -223,6 +233,7 @@ export const ActionTypeForm = ({ valueOfSelected={selectedActionGroup.id} onChange={(group) => { setActionGroupIdByIndex(group, index); + setActionGroup(group); }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index cf2dda203bb2d..1fd031cda6d96 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -10,7 +10,13 @@ import type { DocLinksStart } from 'kibana/public'; import { ComponentType } from 'react'; import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { ActionType } from '../../actions/common'; +import { + ActionType, + AlertHistoryEsIndexConnectorId, + AlertHistoryDocumentTemplate, + ALERT_HISTORY_PREFIX, + AlertHistoryDefaultIndexName, +} from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; import { ActionGroup, @@ -45,7 +51,13 @@ export { AlertNotifyWhenType, AlertTypeParams, }; -export { ActionType }; +export { + ActionType, + AlertHistoryEsIndexConnectorId, + AlertHistoryDocumentTemplate, + AlertHistoryDefaultIndexName, + ALERT_HISTORY_PREFIX, +}; export type ActionTypeIndex = Record; export type AlertTypeIndex = Map; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 426d3f1f10db8..4ba836c1e5d26 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -1,8 +1,16 @@ { - "configPath": ["xpack", "uptime"], + "configPath": [ + "xpack", + "uptime" + ], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["data", "home", "observability", "ml"], + "optionalPlugins": [ + "data", + "home", + "observability", + "ml" + ], "requiredPlugins": [ "alerting", "embeddable", @@ -14,5 +22,12 @@ "server": true, "ui": true, "version": "8.0.0", - "requiredBundles": ["observability", "kibanaReact", "kibanaUtils", "home", "data", "ml", "maps"] -} + "requiredBundles": [ + "observability", + "kibanaReact", + "kibanaUtils", + "home", + "data", + "ml" + ] +} \ No newline at end of file diff --git a/x-pack/plugins/uptime/public/components/monitor/index.ts b/x-pack/plugins/uptime/public/components/monitor/index.ts index 73ac77a61461f..2c95ac3347723 100644 --- a/x-pack/plugins/uptime/public/components/monitor/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/index.ts @@ -7,7 +7,6 @@ export * from './ml'; export * from './ping_list'; -export * from './status_details/location_map'; export * from './status_details'; export * from './ping_histogram'; export * from './monitor_charts'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap deleted file mode 100644 index 94cbeb49a32cf..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap +++ /dev/null @@ -1,234 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationAvailability component doesnt shows warning if geo is provided 1`] = ` - - - - - - - - - - - - - -`; - -exports[`LocationAvailability component renders correctly against snapshot 1`] = ` - - - - -

- Monitoring from -

-
-
- - - -
- - - - - -
-`; - -exports[`LocationAvailability component renders named locations that have missing geo data 1`] = ` - - - - - - - - - - - - - - - -`; - -exports[`LocationAvailability component shows warning if geo information is missing 1`] = ` - - - - - - - - - - - - - - - -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.test.tsx index 2edb2eec46580..855b8ef0c9767 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.test.tsx @@ -6,28 +6,16 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { screen } from '@testing-library/react'; +import { render } from '../../../../lib/helper/rtl_helpers'; import { LocationAvailability } from './location_availability'; import { MonitorLocations } from '../../../../../common/runtime_types'; -import { LocationMissingWarning } from '../location_map/location_missing'; // Note For shallow test, we need absolute time strings describe('LocationAvailability component', () => { let monitorLocations: MonitorLocations; - let localStorageMock: any; - - let selectedView = 'list'; beforeEach(() => { - localStorageMock = { - getItem: jest.fn().mockImplementation(() => selectedView), - setItem: jest.fn(), - }; - - Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - }); - monitorLocations = { monitorId: 'wapo', up_history: 12, @@ -41,104 +29,34 @@ describe('LocationAvailability component', () => { down_history: 0, }, { - summary: { up: 4, down: 0 }, - geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: '2020-01-13T22:50:04.354Z', - up_history: 4, - down_history: 0, - }, - { - summary: { up: 4, down: 0 }, - geo: { name: 'Unnamed-location' }, - timestamp: '2020-01-13T22:50:02.753Z', - up_history: 4, - down_history: 0, - }, - ], - }; - }); - - it('renders correctly against snapshot', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('shows warning if geo information is missing', () => { - selectedView = 'map'; - monitorLocations = { - monitorId: 'wapo', - up_history: 8, - down_history: 0, - locations: [ - { - summary: { up: 4, down: 0 }, + summary: { up: 2, down: 2 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: '2020-01-13T22:50:04.354Z', - up_history: 4, - down_history: 0, + up_history: 2, + down_history: 2, }, { - summary: { up: 4, down: 0 }, + summary: { up: 0, down: 4 }, geo: { name: 'Unnamed-location' }, timestamp: '2020-01-13T22:50:02.753Z', - up_history: 4, - down_history: 0, + up_history: 0, + down_history: 4, }, ], }; - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - - const warningComponent = component.find(LocationMissingWarning); - expect(warningComponent).toHaveLength(1); }); - it('doesnt shows warning if geo is provided', () => { - monitorLocations = { - monitorId: 'wapo', - up_history: 8, - down_history: 0, - locations: [ - { - summary: { up: 4, down: 0 }, - geo: { name: 'New York', location: { lat: '40.730610', lon: ' -73.935242' } }, - timestamp: '2020-01-13T22:50:06.536Z', - up_history: 4, - down_history: 0, - }, - { - summary: { up: 4, down: 0 }, - geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: '2020-01-13T22:50:04.354Z', - up_history: 4, - down_history: 0, - }, - ], - }; - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - - const warningComponent = component.find(LocationMissingWarning); - expect(warningComponent).toHaveLength(0); - }); - - it('renders named locations that have missing geo data', () => { - monitorLocations = { - monitorId: 'wapo', - up_history: 4, - down_history: 0, - locations: [ - { - summary: { up: 4, down: 0 }, - geo: { name: 'New York', location: undefined }, - timestamp: '2020-01-13T22:50:06.536Z', - up_history: 4, - down_history: 0, - }, - ], - }; - - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); + it('renders correctly', () => { + render(); + expect(screen.getByRole('heading', { name: 'Monitoring from', level: 3 })); + expect(screen.getByText('New York')).toBeInTheDocument(); + expect(screen.getByText('Tokyo')).toBeInTheDocument(); + expect(screen.getByText('Unnamed-location')).toBeInTheDocument(); + expect(screen.getByText('100.00 %')).toBeInTheDocument(); + expect(screen.getByText('50.00 %')).toBeInTheDocument(); + expect(screen.getByText('0.00 %')).toBeInTheDocument(); + expect(screen.getByText('Jan 13, 2020 5:50:06 PM')).toBeInTheDocument(); + expect(screen.getByText('Jan 13, 2020 5:50:04 PM')).toBeInTheDocument(); + expect(screen.getByText('Jan 13, 2020 5:50:02 PM')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx index 5f74098e12583..c851369d63e9e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx @@ -5,18 +5,12 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiErrorBoundary, EuiTitle } from '@elastic/eui'; import { LocationStatusTags } from '../availability_reporting'; -import { LocationPoint } from '../location_map/embeddables/embedded_map'; -import { MonitorLocations, MonitorLocation } from '../../../../../common/runtime_types'; -import { UNNAMED_LOCATION } from '../../../../../common/constants'; -import { LocationMissingWarning } from '../location_map/location_missing'; -import { useSelectedView } from './use_selected_view'; -import { LocationMap } from '../location_map'; +import { MonitorLocations } from '../../../../../common/runtime_types'; import { MonitoringFrom } from '../translations'; -import { ToggleViewBtn } from './toggle_view_btn'; const EuiFlexItemTags = styled(EuiFlexItem)` width: 350px; @@ -30,61 +24,20 @@ interface LocationMapProps { } export const LocationAvailability = ({ monitorLocations }: LocationMapProps) => { - const upPoints: LocationPoint[] = []; - const downPoints: LocationPoint[] = []; - - let isAnyGeoInfoMissing = false; - - if (monitorLocations?.locations) { - monitorLocations.locations.forEach(({ geo, summary }: MonitorLocation) => { - if (geo?.name === UNNAMED_LOCATION || !geo?.location) { - isAnyGeoInfoMissing = true; - } else if (!!geo.location.lat && !!geo.location.lon) { - if (summary?.down === 0) { - upPoints.push(geo as LocationPoint); - } else { - downPoints.push(geo as LocationPoint); - } - } - }); - } - const { selectedView: initialView } = useSelectedView(); - - const [selectedView, setSelectedView] = useState(initialView); - return ( - {selectedView === 'list' && ( - - -

{MonitoringFrom}

-
-
- )} - {selectedView === 'map' && ( - {isAnyGeoInfoMissing && } - )} - - { - setSelectedView(val); - }} - /> + + +

{MonitoringFrom}

+
- {selectedView === 'list' && ( - - - - )} - {selectedView === 'map' && ( - - - - )} + + +
); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx deleted file mode 100644 index 45cb5c45bf021..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as React from 'react'; -import styled from 'styled-components'; -import { EuiButtonGroup } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useSelectedView } from './use_selected_view'; -import { ChangeToListView, ChangeToMapView } from '../translations'; - -const ToggleViewButtons = styled.span` - margin-left: auto; -`; - -interface Props { - onChange: (val: string) => void; -} - -export const ToggleViewBtn = ({ onChange }: Props) => { - const toggleButtons = [ - { - id: `listBtn`, - label: ChangeToMapView, - name: 'listView', - iconType: 'list', - 'data-test-subj': 'uptimeMonitorToggleListBtn', - 'aria-label': ChangeToMapView, - }, - { - id: `mapBtn`, - label: ChangeToListView, - name: 'mapView', - iconType: 'mapMarker', - 'data-test-subj': 'uptimeMonitorToggleMapBtn', - 'aria-label': ChangeToListView, - }, - ]; - - const { selectedView, setSelectedView } = useSelectedView(); - - const onChangeView = (optionId: string) => { - const currView = optionId === 'listBtn' ? 'list' : 'map'; - setSelectedView(currView); - onChange(currView); - }; - - return ( - - onChangeView(id)} - type="multi" - isIconOnly - style={{ marginLeft: 'auto' }} - legend={i18n.translate('xpack.uptime.locationAvailabilityViewToggleLegend', { - defaultMessage: 'View toggle', - })} - /> - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts deleted file mode 100644 index fa77d0bf9057e..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts +++ /dev/null @@ -1,27 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useState } from 'react'; - -const localKey = 'xpack.uptime.detailPage.selectedView'; - -interface Props { - selectedView: string; - setSelectedView: (val: string) => void; -} - -export const useSelectedView = (): Props => { - const getSelectedView = localStorage.getItem(localKey) ?? 'list'; - - const [selectedView, setSelectedView] = useState(getSelectedView); - - useEffect(() => { - localStorage.setItem(localKey, selectedView); - }, [selectedView]); - - return { selectedView, setSelectedView }; -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_map.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_map.test.tsx.snap deleted file mode 100644 index 6b3d157c23fee..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_map.test.tsx.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationMap component renders correctly against snapshot 1`] = ` - - - -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_missing.test.tsx.snap deleted file mode 100644 index 5e3e2e1a6db46..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__snapshots__/location_missing.test.tsx.snap +++ /dev/null @@ -1,123 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationMissingWarning component renders correctly against snapshot 1`] = ` -.c0 { - margin-left: auto; - margin-bottom: 3px; - margin-right: 5px; -} - -
-
-
-
- -
-
-
-
-`; - -exports[`LocationMissingWarning component shallow render correctly against snapshot 1`] = ` - - - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="popover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > - - - observer.geo.?? - , - } - } - /> - - - - - - - - - - -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__mocks__/poly_layer_mock.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__mocks__/poly_layer_mock.ts deleted file mode 100644 index b925697970a57..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__mocks__/poly_layer_mock.ts +++ /dev/null @@ -1,192 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import lowPolyLayerFeatures from '../low_poly_layer.json'; - -export const mockDownPointsLayer = { - id: 'down_points', - label: 'Down Locations', - sourceDescriptor: { - type: 'GEOJSON_FILE', - __featureCollection: { - features: [ - { - id: 'Asia', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [13.399262, 52.487239], - }, - }, - { - id: 'APJ', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [13.399262, 55.487239], - }, - }, - { - id: 'Canada', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [14.399262, 54.487239], - }, - }, - ], - type: 'FeatureCollection', - }, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: '#BC261E', - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 2, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', -}; - -export const mockUpPointsLayer = { - id: 'up_points', - label: 'Up Locations', - sourceDescriptor: { - type: 'GEOJSON_FILE', - __featureCollection: { - features: [ - { - id: 'US-EAST', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [13.399262, 52.487239], - }, - }, - { - id: 'US-WEST', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [13.399262, 55.487239], - }, - }, - { - id: 'Europe', - type: 'feature', - geometry: { - type: 'Point', - coordinates: [14.399262, 54.487239], - }, - }, - ], - type: 'FeatureCollection', - }, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: '#98A2B2', - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 2, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', -}; - -export const mockLayerList = [ - { - id: 'low_poly_layer', - label: 'World countries', - minZoom: 0, - maxZoom: 24, - alpha: 1, - sourceDescriptor: { - id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854c', - type: 'GEOJSON_FILE', - __featureCollection: lowPolyLayerFeatures, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: '#cad3e4', - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 0, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', - }, - mockDownPointsLayer, - mockUpPointsLayer, -]; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx deleted file mode 100644 index 6706a435c7b6b..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx +++ /dev/null @@ -1,174 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect, useState, useContext, useRef } from 'react'; -import uuid from 'uuid'; -import styled from 'styled-components'; -import { createPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; -import { - MapEmbeddable, - MapEmbeddableInput, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../../maps/public/embeddable'; -import * as i18n from './translations'; -import { GeoPoint } from '../../../../../../common/runtime_types'; -import { getLayerList } from './map_config'; -import { UptimeThemeContext, UptimeStartupPluginsContext } from '../../../../../contexts'; -import { - isErrorEmbeddable, - ViewMode, - ErrorEmbeddable, -} from '../../../../../../../../../src/plugins/embeddable/public'; -import { MAP_SAVED_OBJECT_TYPE } from '../../../../../../../maps/public'; -import { MapToolTipComponent } from './map_tool_tip'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { RenderTooltipContentParams } from '../../../../../../../maps/public/classes/tooltips/tooltip_property'; - -export interface EmbeddedMapProps { - upPoints: LocationPoint[]; - downPoints: LocationPoint[]; -} - -export type LocationPoint = Required; - -const EmbeddedPanel = styled.div` - z-index: auto; - flex: 1; - display: flex; - flex-direction: column; - height: 100%; - position: relative; - .embPanel__content { - display: flex; - flex: 1 1 100%; - z-index: 1; - min-height: 0; // Absolute must for Firefox to scroll contents - } - &&& .mapboxgl-canvas { - animation: none !important; - } -`; - -export const EmbeddedMap = React.memo(({ upPoints, downPoints }: EmbeddedMapProps) => { - const { colors } = useContext(UptimeThemeContext); - const [embeddable, setEmbeddable] = useState(); - const embeddableRoot: React.RefObject = useRef(null); - const { embeddable: embeddablePlugin } = useContext(UptimeStartupPluginsContext); - if (!embeddablePlugin) { - throw new Error('Embeddable start plugin not found'); - } - const factory: any = embeddablePlugin.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE); - - const portalNode = React.useMemo(() => createPortalNode(), []); - - const input: MapEmbeddableInput = { - id: uuid.v4(), - attributes: { title: '' }, - filters: [], - hidePanelTitles: true, - refreshConfig: { - value: 0, - pause: false, - }, - viewMode: ViewMode.VIEW, - isLayerTOCOpen: false, - hideFilterActions: true, - // Zoom Lat/Lon values are set to make sure map is in center in the panel - // It wil also omit Greenland/Antarctica etc - mapCenter: { - lon: 11, - lat: 20, - zoom: 0, - }, - mapSettings: { - disableInteractive: true, - hideToolbarOverlay: true, - hideLayerControl: true, - hideViewControl: true, - }, - }; - - const renderTooltipContent = ({ - addFilters, - closeTooltip, - features, - isLocked, - getLayerName, - loadFeatureProperties, - loadFeatureGeometry, - }: RenderTooltipContentParams) => { - const props = { - addFilters, - closeTooltip, - isLocked, - getLayerName, - loadFeatureProperties, - loadFeatureGeometry, - }; - const relevantFeatures = features.filter( - (item: any) => item.layerId === 'up_points' || item.layerId === 'down_points' - ); - if (relevantFeatures.length > 0) { - return ; - } - closeTooltip(); - return null; - }; - - useEffect(() => { - async function setupEmbeddable() { - if (!factory) { - throw new Error('Map embeddable not found.'); - } - const embeddableObject: any = await factory.create({ - ...input, - title: i18n.MAP_TITLE, - }); - - if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { - embeddableObject.setRenderTooltipContent(renderTooltipContent); - embeddableObject.setLayerList(getLayerList(upPoints, downPoints, colors)); - } - - setEmbeddable(embeddableObject); - } - - setupEmbeddable(); - - // we want this effect to execute exactly once after the component mounts - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // update map layers based on points - useEffect(() => { - if (embeddable && !isErrorEmbeddable(embeddable)) { - embeddable.setLayerList(getLayerList(upPoints, downPoints, colors)); - } - }, [upPoints, downPoints, embeddable, colors]); - - // We can only render after embeddable has already initialized - useEffect(() => { - if (embeddableRoot.current && embeddable) { - embeddable.render(embeddableRoot.current); - } - }, [embeddable, embeddableRoot]); - - return ( - -
- - - - - ); -}); - -EmbeddedMap.displayName = 'EmbeddedMap'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/low_poly_layer.json b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/low_poly_layer.json deleted file mode 100644 index 7a309cd01ebc7..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/low_poly_layer.json +++ /dev/null @@ -1,2898 +0,0 @@ -{ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [ - "34.21666", - "31.32333" - ], - [ - "35.98361", - "34.52750" - ], - [ - "34.65943", - "36.80527" - ], - [ - "32.77166", - "36.02888" - ], - [ - "29.67722", - "36.11833" - ], - [ - "27.25500", - "36.96500" - ], - [ - "27.51166", - "40.30555" - ], - [ - "33.33860", - "42.01985" - ], - [ - "38.35582", - "40.91027" - ], - [ - "41.77609", - "41.84193" - ], - [ - "41.59748", - "43.22151" - ], - [ - "45.16512", - "42.70333" - ], - [ - "47.91547", - "41.22499" - ], - [ - "49.76062", - "42.71076" - ], - [ - "49.44831", - "45.53038" - ], - [ - "47.30249", - "50.03194" - ], - [ - "52.34180", - "51.78075" - ], - [ - "55.69249", - "50.53249" - ], - [ - "58.33777", - "51.15610" - ], - [ - "57.97027", - "54.38819" - ], - [ - "59.64166", - "55.55867" - ], - [ - "57.22169", - "56.85096" - ], - [ - "59.44912", - "58.48804" - ], - [ - "59.57756", - "63.93287" - ], - [ - "66.10887", - "67.48123" - ], - [ - "64.52222", - "68.90305" - ], - [ - "67.05498", - "68.85637" - ], - [ - "69.32735", - "72.94540" - ], - [ - "73.52553", - "71.81582" - ], - [ - "80.82610", - "72.08693" - ], - [ - "80.51860", - "73.57346" - ], - [ - "89.25278", - "75.50305" - ], - [ - "97.18359", - "75.92804" - ], - [ - "104.07138", - "77.73221" - ], - [ - "111.10387", - "76.75526" - ], - [ - "113.47054", - "73.50096" - ], - [ - "118.63443", - "73.57166" - ], - [ - "131.53580", - "70.87776" - ], - [ - "137.45190", - "71.34109" - ], - [ - "141.02414", - "72.58582" - ], - [ - "149.18524", - "72.22249" - ], - [ - "152.53830", - "70.83777" - ], - [ - "159.72968", - "69.83472" - ], - [ - "170.61194", - "68.75633" - ], - [ - "170.47189", - "70.13416" - ], - [ - "180.00000", - "68.98010" - ], - [ - "180.00000", - "65.06891" - ], - [ - "179.55373", - "62.61971" - ], - [ - "173.54178", - "61.74430" - ], - [ - "170.64194", - "60.41750" - ], - [ - "163.36023", - "59.82388" - ], - [ - "161.93858", - "58.06763" - ], - [ - "163.34996", - "56.19596" - ], - [ - "156.74524", - "51.07791" - ], - [ - "155.54413", - "55.30360" - ], - [ - "155.94206", - "56.65353" - ], - [ - "161.91248", - "60.41972" - ], - [ - "159.24747", - "61.92222" - ], - [ - "152.35718", - "59.02332" - ], - [ - "143.21109", - "59.37666" - ], - [ - "137.72580", - "56.17500" - ], - [ - "137.29327", - "54.07500" - ], - [ - "141.41483", - "53.29361" - ], - [ - "140.17609", - "48.45013" - ], - [ - "135.42233", - "43.75611" - ], - [ - "133.15485", - "42.68263" - ], - [ - "131.81052", - "43.32555" - ], - [ - "129.70204", - "40.83069" - ], - [ - "127.51763", - "39.73957" - ], - [ - "129.42944", - "37.05986" - ], - [ - "129.23749", - "35.18990" - ], - [ - "126.37556", - "34.79138" - ], - [ - "126.38860", - "37.88721" - ], - [ - "124.32395", - "39.91589" - ], - [ - "121.64804", - "38.99638" - ], - [ - "121.17747", - "40.92194" - ], - [ - "118.11053", - "38.14639" - ], - [ - "120.82054", - "36.64527" - ], - [ - "120.24873", - "34.31145" - ], - [ - "121.84693", - "30.85305" - ], - [ - "120.93526", - "27.98222" - ], - [ - "119.58074", - "25.67996" - ], - [ - "116.48172", - "22.93902" - ], - [ - "112.28194", - "21.70139" - ], - [ - "107.36693", - "21.26527" - ], - [ - "105.63857", - "18.89065" - ], - [ - "108.82916", - "15.42194" - ], - [ - "109.46186", - "12.86097" - ], - [ - "109.02168", - "11.36225" - ], - [ - "104.79893", - "8.79222" - ], - [ - "104.98177", - "10.10444" - ], - [ - "100.97635", - "13.46281" - ], - [ - "99.15082", - "10.36472" - ], - [ - "100.57809", - "7.22014" - ], - [ - "103.18192", - "5.28278" - ], - [ - "103.37455", - "1.53347" - ], - [ - "101.28574", - "2.84354" - ], - [ - "100.35553", - "5.96389" - ], - [ - "98.27415", - "8.27444" - ], - [ - "98.74720", - "11.67486" - ], - [ - "97.72457", - "15.84666" - ], - [ - "95.42859", - "15.72972" - ], - [ - "93.72436", - "19.93243" - ], - [ - "91.70444", - "22.48055" - ], - [ - "86.96332", - "21.38194" - ], - [ - "86.42123", - "19.98493" - ], - [ - "80.27943", - "15.69917" - ], - [ - "79.85811", - "10.28583" - ], - [ - "76.99860", - "8.36527" - ], - [ - "74.85526", - "12.75500" - ], - [ - "73.44748", - "16.05861" - ], - [ - "72.56485", - "21.37506" - ], - [ - "70.82513", - "20.69597" - ], - [ - "66.50005", - "25.40381" - ], - [ - "61.76083", - "25.03208" - ], - [ - "57.31909", - "25.77146" - ], - [ - "56.80888", - "27.12361" - ], - [ - "54.78846", - "26.49041" - ], - [ - "51.43027", - "27.93777" - ], - [ - "50.63916", - "29.47042" - ], - [ - "47.95943", - "30.03305" - ], - [ - "48.83887", - "27.61972" - ], - [ - "51.28236", - "24.30000" - ], - [ - "53.58777", - "24.04417" - ], - [ - "55.85944", - "25.72042" - ], - [ - "57.17131", - "23.93444" - ], - [ - "59.82861", - "22.29166" - ], - [ - "57.80569", - "18.97097" - ], - [ - "55.03194", - "17.01472" - ], - [ - "52.18916", - "15.60528" - ], - [ - "45.04232", - "12.75239" - ], - [ - "43.47888", - "12.67500" - ], - [ - "42.78933", - "16.46083" - ], - [ - "40.75694", - "19.76417" - ], - [ - "39.17486", - "21.10402" - ], - [ - "39.06277", - "22.58333" - ], - [ - "35.16055", - "28.05666" - ], - [ - "34.21666", - "31.32333" - ] - ] - ], - [ - [ - [ - "-169.69496", - "66.06806" - ], - [ - "-173.67308", - "64.34679" - ], - [ - "-179.32083", - "65.53012" - ], - [ - "-180.00000", - "65.06891" - ], - [ - "-180.00000", - "68.98010" - ], - [ - "-169.69496", - "66.06806" - ] - ] - ], - [ - [ - [ - "139.93851", - "40.42860" - ], - [ - "142.06970", - "39.54666" - ], - [ - "140.95358", - "38.14805" - ], - [ - "140.33218", - "35.12985" - ], - [ - "137.02879", - "34.56784" - ], - [ - "136.71246", - "36.75139" - ], - [ - "139.42622", - "38.15458" - ], - [ - "139.93851", - "40.42860" - ] - ] - ], - [ - [ - [ - "119.89259", - "15.80112" - ], - [ - "120.58527", - "18.51139" - ], - [ - "122.51833", - "17.04389" - ], - [ - "121.38026", - "15.30250" - ], - [ - "119.89259", - "15.80112" - ] - ] - ], - [ - [ - [ - "122.32916", - "7.30833" - ], - [ - "126.18610", - "9.24277" - ], - [ - "125.37762", - "6.72361" - ], - [ - "123.45888", - "7.81055" - ], - [ - "122.32916", - "7.30833" - ] - ] - ], - [ - [ - [ - "111.89638", - "-3.57389" - ], - [ - "110.23193", - "-2.97111" - ], - [ - "108.84549", - "0.81056" - ], - [ - "109.64857", - "2.07341" - ], - [ - "113.01054", - "3.16055" - ], - [ - "115.37886", - "4.91167" - ], - [ - "116.75417", - "7.01805" - ], - [ - "119.27582", - "5.34500" - ], - [ - "117.27540", - "3.22000" - ], - [ - "117.87192", - "1.87667" - ], - [ - "117.44479", - "-0.52397" - ], - [ - "115.96624", - "-3.60875" - ], - [ - "113.03471", - "-2.98972" - ], - [ - "111.89638", - "-3.57389" - ] - ] - ], - [ - [ - [ - "102.97601", - "0.64348" - ], - [ - "103.36081", - "-0.70222" - ], - [ - "106.05525", - "-3.03139" - ], - [ - "105.72887", - "-5.89826" - ], - [ - "102.32610", - "-4.00611" - ], - [ - "100.90555", - "-2.31944" - ], - [ - "98.70383", - "1.55979" - ], - [ - "95.53108", - "4.68278" - ], - [ - "97.51483", - "5.24944" - ], - [ - "100.41219", - "2.29306" - ], - [ - "102.97601", - "0.64348" - ] - ] - ], - [ - [ - [ - "120.82723", - "1.23406" - ], - [ - "120.01999", - "-0.07528" - ], - [ - "122.47623", - "-3.16090" - ], - [ - "120.32888", - "-5.51208" - ], - [ - "119.35491", - "-5.40007" - ], - [ - "118.88860", - "-2.89319" - ], - [ - "119.77805", - "0.22972" - ], - [ - "120.82723", - "1.23406" - ] - ] - ], - [ - [ - [ - "136.04913", - "-2.69806" - ], - [ - "137.87579", - "-1.47306" - ], - [ - "144.51373", - "-3.82222" - ], - [ - "145.76639", - "-5.48528" - ], - [ - "147.46661", - "-5.97086" - ], - [ - "146.08969", - "-8.09111" - ], - [ - "144.21738", - "-7.79465" - ], - [ - "143.36510", - "-9.01222" - ], - [ - "141.11996", - "-9.23097" - ], - [ - "139.09454", - "-7.56181" - ], - [ - "138.06525", - "-5.40896" - ], - [ - "135.20468", - "-4.45972" - ], - [ - "132.72275", - "-2.81722" - ], - [ - "131.25555", - "-0.82278" - ], - [ - "134.02950", - "-0.96694" - ], - [ - "134.99495", - "-3.33653" - ], - [ - "136.04913", - "-2.69806" - ] - ] - ], - [ - [ - [ - "110.05640", - "-7.89751" - ], - [ - "106.56721", - "-7.41694" - ], - [ - "106.07582", - "-5.88194" - ], - [ - "110.39360", - "-6.97903" - ], - [ - "110.05640", - "-7.89751" - ] - ] - ] - ] - }, - "properties": { - "CONTINENT": "Asia" - } - }, - { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [ - "-25.28167", - "71.39166" - ], - [ - "-23.56056", - "70.10609" - ], - [ - "-26.36333", - "68.66748" - ], - [ - "-31.99916", - "68.09526" - ], - [ - "-34.71999", - "66.33832" - ], - [ - "-41.15541", - "64.96235" - ], - [ - "-43.08722", - "60.10027" - ], - [ - "-47.68986", - "61.00680" - ], - [ - "-50.31562", - "62.49430" - ], - [ - "-53.23333", - "65.68283" - ], - [ - "-53.62778", - "67.81470" - ], - [ - "-50.58930", - "69.92373" - ], - [ - "-54.68694", - "72.36721" - ], - [ - "-58.15958", - "75.50860" - ], - [ - "-68.50056", - "76.08693" - ], - [ - "-72.55222", - "78.52110" - ], - [ - "-60.80666", - "81.87997" - ], - [ - "-30.38833", - "83.60220" - ], - [ - "-16.00500", - "80.72859" - ], - [ - "-22.03695", - "77.68568" - ], - [ - "-19.33681", - "75.40207" - ], - [ - "-24.46305", - "73.53581" - ], - [ - "-25.28167", - "71.39166" - ] - ] - ], - [ - [ - [ - "-87.64890", - "76.33804" - ], - [ - "-86.47916", - "79.76167" - ], - [ - "-90.43666", - "81.88750" - ], - [ - "-70.26001", - "83.11388" - ], - [ - "-61.07639", - "82.32083" - ], - [ - "-78.78194", - "76.57221" - ], - [ - "-87.64890", - "76.33804" - ] - ] - ], - [ - [ - [ - "-123.83389", - "73.70027" - ], - [ - "-115.31903", - "73.47707" - ], - [ - "-123.29306", - "71.14610" - ], - [ - "-123.83389", - "73.70027" - ] - ] - ], - [ - [ - [ - "-65.32806", - "62.66610" - ], - [ - "-68.61583", - "62.26389" - ], - [ - "-77.33667", - "65.17609" - ], - [ - "-72.25835", - "67.24803" - ], - [ - "-77.30506", - "69.83395" - ], - [ - "-85.87465", - "70.07943" - ], - [ - "-89.90348", - "71.35304" - ], - [ - "-89.03958", - "73.25499" - ], - [ - "-81.57251", - "73.71971" - ], - [ - "-67.21986", - "69.94081" - ], - [ - "-67.23819", - "68.35790" - ], - [ - "-61.26458", - "66.62609" - ], - [ - "-65.56204", - "64.73154" - ], - [ - "-65.32806", - "62.66610" - ] - ] - ], - [ - [ - [ - "-105.02444", - "72.21999" - ], - [ - "-100.99973", - "70.17276" - ], - [ - "-101.85139", - "68.98442" - ], - [ - "-113.04173", - "68.49374" - ], - [ - "-116.53221", - "69.40887" - ], - [ - "-119.13445", - "71.77457" - ], - [ - "-114.66666", - "73.37247" - ], - [ - "-105.02444", - "72.21999" - ] - ] - ], - [ - [ - [ - "-77.36667", - "8.67500" - ], - [ - "-77.88972", - "7.22889" - ], - [ - "-79.69778", - "8.86666" - ], - [ - "-81.73862", - "8.16250" - ], - [ - "-85.65668", - "9.90500" - ], - [ - "-85.66959", - "11.05500" - ], - [ - "-87.93779", - "13.15639" - ], - [ - "-91.38474", - "13.97889" - ], - [ - "-93.93861", - "16.09389" - ], - [ - "-96.47612", - "15.64361" - ], - [ - "-103.45001", - "18.31361" - ], - [ - "-105.67834", - "20.38305" - ], - [ - "-105.18945", - "21.43750" - ], - [ - "-106.91570", - "23.86514" - ], - [ - "-109.43750", - "25.82027" - ], - [ - "-109.44431", - "26.71555" - ], - [ - "-112.16195", - "28.97139" - ], - [ - "-113.09167", - "31.22972" - ], - [ - "-115.69667", - "29.77423" - ], - [ - "-117.40944", - "33.24416" - ], - [ - "-120.60583", - "34.55860" - ], - [ - "-124.33118", - "40.27246" - ], - [ - "-124.52444", - "42.86610" - ], - [ - "-123.87161", - "45.52898" - ], - [ - "-124.71431", - "48.39708" - ], - [ - "-124.03510", - "49.91801" - ], - [ - "-127.17315", - "50.92221" - ], - [ - "-130.88640", - "55.70791" - ], - [ - "-133.81302", - "57.97293" - ], - [ - "-136.65891", - "58.21652" - ], - [ - "-140.40335", - "59.69804" - ], - [ - "-146.75543", - "60.95249" - ], - [ - "-154.23567", - "58.13069" - ], - [ - "-157.55139", - "58.38777" - ], - [ - "-165.42244", - "60.55215" - ], - [ - "-164.40112", - "63.21499" - ], - [ - "-168.13196", - "65.66296" - ], - [ - "-161.66779", - "67.02054" - ], - [ - "-166.82362", - "68.34873" - ], - [ - "-156.59673", - "71.35144" - ], - [ - "-151.22986", - "70.37296" - ], - [ - "-143.21555", - "70.11026" - ], - [ - "-137.25500", - "68.94832" - ], - [ - "-127.18096", - "70.27638" - ], - [ - "-114.06652", - "68.46970" - ], - [ - "-112.39584", - "67.67915" - ], - [ - "-98.11124", - "67.83887" - ], - [ - "-90.43639", - "68.87442" - ], - [ - "-85.55499", - "69.85970" - ], - [ - "-81.33570", - "69.18498" - ], - [ - "-81.50222", - "67.00096" - ], - [ - "-85.89726", - "66.16802" - ], - [ - "-87.98736", - "64.18845" - ], - [ - "-92.71001", - "62.46583" - ], - [ - "-94.78972", - "59.09222" - ], - [ - "-92.41875", - "57.33270" - ], - [ - "-88.81500", - "56.82444" - ], - [ - "-85.00195", - "55.29666" - ], - [ - "-82.30777", - "55.14888" - ], - [ - "-82.27390", - "52.95638" - ], - [ - "-78.57945", - "52.11138" - ], - [ - "-79.76181", - "54.65166" - ], - [ - "-76.67979", - "56.03645" - ], - [ - "-78.57299", - "58.62888" - ], - [ - "-77.50835", - "62.56166" - ], - [ - "-73.68346", - "62.47999" - ], - [ - "-70.14848", - "61.08458" - ], - [ - "-67.56610", - "58.22360" - ], - [ - "-64.74538", - "60.23075" - ], - [ - "-61.09055", - "55.84415" - ], - [ - "-57.34969", - "54.57496" - ], - [ - "-56.95160", - "51.42458" - ], - [ - "-60.00500", - "50.24888" - ], - [ - "-66.44903", - "50.26777" - ], - [ - "-64.21167", - "48.88499" - ], - [ - "-64.90430", - "46.84597" - ], - [ - "-63.66708", - "45.81666" - ], - [ - "-70.19187", - "43.57555" - ], - [ - "-70.72610", - "41.72777" - ], - [ - "-74.13390", - "40.70082" - ], - [ - "-75.96083", - "37.15221" - ], - [ - "-76.34326", - "34.88194" - ], - [ - "-78.82750", - "33.73027" - ], - [ - "-81.48843", - "31.11347" - ], - [ - "-80.03534", - "26.79569" - ], - [ - "-81.73659", - "25.95944" - ], - [ - "-84.01098", - "30.09764" - ], - [ - "-88.98083", - "30.41833" - ], - [ - "-94.75417", - "29.36791" - ], - [ - "-97.56041", - "26.84208" - ], - [ - "-97.74223", - "22.01250" - ], - [ - "-95.80112", - "18.74500" - ], - [ - "-94.46918", - "18.14625" - ], - [ - "-90.73167", - "19.36153" - ], - [ - "-90.27972", - "21.06305" - ], - [ - "-86.82973", - "21.42923" - ], - [ - "-88.28250", - "17.62389" - ], - [ - "-88.13696", - "15.68285" - ], - [ - "-84.26015", - "15.82597" - ], - [ - "-83.18695", - "14.32389" - ], - [ - "-83.84751", - "11.17458" - ], - [ - "-82.24278", - "9.00236" - ], - [ - "-79.53445", - "9.62014" - ], - [ - "-77.36667", - "8.67500" - ] - ] - ], - [ - [ - [ - "-55.19333", - "46.98499" - ], - [ - "-59.40361", - "47.89423" - ], - [ - "-56.68250", - "51.33943" - ], - [ - "-55.56114", - "49.36818" - ], - [ - "-52.83465", - "48.09965" - ], - [ - "-55.19333", - "46.98499" - ] - ] - ], - [ - [ - [ - "-73.03644", - "18.45622" - ], - [ - "-72.79834", - "19.94278" - ], - [ - "-69.94932", - "19.67680" - ], - [ - "-68.89528", - "18.39639" - ], - [ - "-73.03644", - "18.45622" - ] - ] - ] - ] - }, - "properties": { - "CONTINENT": "North America" - } - }, - { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [ - "64.52222", - "68.90305" - ], - [ - "66.10887", - "67.48123" - ], - [ - "59.57756", - "63.93287" - ], - [ - "59.44912", - "58.48804" - ], - [ - "57.22169", - "56.85096" - ], - [ - "59.64166", - "55.55867" - ], - [ - "57.97027", - "54.38819" - ], - [ - "58.33777", - "51.15610" - ], - [ - "55.69249", - "50.53249" - ], - [ - "52.34180", - "51.78075" - ], - [ - "47.30249", - "50.03194" - ], - [ - "49.44831", - "45.53038" - ], - [ - "49.76062", - "42.71076" - ], - [ - "47.91547", - "41.22499" - ], - [ - "45.16512", - "42.70333" - ], - [ - "41.59748", - "43.22151" - ], - [ - "39.94553", - "43.39693" - ], - [ - "34.70249", - "46.17582" - ], - [ - "30.83277", - "46.54832" - ], - [ - "28.78083", - "44.66096" - ], - [ - "28.01305", - "41.98222" - ], - [ - "26.36041", - "40.95388" - ], - [ - "22.59500", - "40.01221" - ], - [ - "23.96055", - "38.28166" - ], - [ - "22.15246", - "37.01854" - ], - [ - "19.30721", - "40.64531" - ], - [ - "19.59771", - "41.80611" - ], - [ - "15.15167", - "44.19639" - ], - [ - "13.02958", - "41.26014" - ], - [ - "8.74722", - "44.42805" - ], - [ - "6.16528", - "43.05055" - ], - [ - "4.05625", - "43.56277" - ], - [ - "3.20167", - "41.89278" - ], - [ - "0.99306", - "41.04805" - ], - [ - "0.20722", - "38.73221" - ], - [ - "-2.12292", - "36.73347" - ], - [ - "-5.61361", - "36.00610" - ], - [ - "-6.95992", - "37.22184" - ], - [ - "-8.98924", - "37.02631" - ], - [ - "-9.49083", - "38.79388" - ], - [ - "-8.66014", - "40.69111" - ], - [ - "-9.16972", - "43.18583" - ], - [ - "-1.44389", - "43.64055" - ], - [ - "-1.11463", - "46.31658" - ], - [ - "-2.68528", - "48.50166" - ], - [ - "1.43875", - "50.10083" - ], - [ - "5.59917", - "53.30028" - ], - [ - "13.80854", - "53.85479" - ], - [ - "21.24506", - "54.95506" - ], - [ - "21.05223", - "56.81749" - ], - [ - "23.43159", - "59.95382" - ], - [ - "21.42416", - "60.57930" - ], - [ - "21.58500", - "64.43971" - ], - [ - "17.09861", - "61.60278" - ], - [ - "19.07264", - "59.73819" - ], - [ - "16.37982", - "56.66333" - ], - [ - "12.46007", - "56.29666" - ], - [ - "10.51569", - "59.30624" - ], - [ - "8.12750", - "58.09888" - ], - [ - "5.50847", - "58.66764" - ], - [ - "4.94944", - "61.41041" - ], - [ - "9.54528", - "63.76611" - ], - [ - "15.28833", - "68.03055" - ], - [ - "21.30000", - "70.24693" - ], - [ - "28.20778", - "71.07999" - ], - [ - "32.80605", - "69.30277" - ], - [ - "43.75180", - "67.31152" - ], - [ - "53.60437", - "68.90818" - ], - [ - "64.52222", - "68.90305" - ] - ] - ], - [ - [ - [ - "-13.49944", - "65.06915" - ], - [ - "-18.77500", - "63.39139" - ], - [ - "-22.04556", - "64.04666" - ], - [ - "-22.42167", - "66.43332" - ], - [ - "-16.41736", - "66.27603" - ], - [ - "-13.49944", - "65.06915" - ] - ] - ], - [ - [ - [ - "-4.19667", - "57.48583" - ], - [ - "-0.07931", - "54.11340" - ], - [ - "0.25389", - "50.73861" - ], - [ - "-3.43722", - "50.60500" - ], - [ - "-4.19639", - "53.20611" - ], - [ - "-2.89979", - "53.72499" - ], - [ - "-6.22778", - "56.69722" - ], - [ - "-4.19667", - "57.48583" - ] - ] - ], - [ - [ - [ - "12.44167", - "37.80611" - ], - [ - "15.64794", - "38.26458" - ], - [ - "15.08139", - "36.64916" - ], - [ - "12.44167", - "37.80611" - ] - ] - ] - ] - }, - "properties": { - "CONTINENT": "Europe" - } - }, - { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [ - "34.21666", - "31.32333" - ], - [ - "34.90380", - "29.48671" - ], - [ - "33.93833", - "26.65528" - ], - [ - "36.88625", - "22.05319" - ], - [ - "37.43569", - "18.85389" - ], - [ - "38.58902", - "18.06680" - ], - [ - "39.71805", - "15.08805" - ], - [ - "41.17222", - "14.63069" - ], - [ - "43.32750", - "12.47673" - ], - [ - "44.27833", - "10.44778" - ], - [ - "50.09319", - "11.51458" - ], - [ - "51.14555", - "10.63361" - ], - [ - "48.00055", - "4.52306" - ], - [ - "46.02555", - "2.43722" - ], - [ - "43.48861", - "0.65000" - ], - [ - "40.12548", - "-3.26569" - ], - [ - "38.77611", - "-6.03972" - ], - [ - "40.38777", - "-11.31778" - ], - [ - "40.57833", - "-15.49889" - ], - [ - "34.89069", - "-19.86042" - ], - [ - "35.45611", - "-24.16945" - ], - [ - "32.81111", - "-25.61209" - ], - [ - "32.39444", - "-28.53139" - ], - [ - "27.90000", - "-33.04056" - ], - [ - "24.82472", - "-34.20167" - ], - [ - "22.53916", - "-34.01118" - ], - [ - "20.00000", - "-34.82200" - ], - [ - "17.84750", - "-32.83083" - ], - [ - "18.21791", - "-31.73458" - ], - [ - "15.09500", - "-26.73528" - ], - [ - "14.51139", - "-22.55278" - ], - [ - "11.76764", - "-17.98820" - ], - [ - "11.73125", - "-15.85070" - ], - [ - "13.84944", - "-10.95611" - ], - [ - "13.39180", - "-8.39375" - ], - [ - "11.77417", - "-4.54264" - ], - [ - "9.70250", - "-2.44792" - ], - [ - "9.29833", - "-0.37167" - ], - [ - "9.96514", - "3.08521" - ], - [ - "8.89861", - "4.58833" - ], - [ - "5.93583", - "4.33833" - ], - [ - "4.41021", - "6.35993" - ], - [ - "1.46889", - "6.18639" - ], - [ - "-2.05889", - "4.73083" - ], - [ - "-4.46806", - "5.29556" - ], - [ - "-7.43639", - "4.34917" - ], - [ - "-9.23889", - "5.12278" - ], - [ - "-12.50417", - "7.38861" - ], - [ - "-13.49313", - "9.56008" - ], - [ - "-15.00542", - "10.77194" - ], - [ - "-17.17556", - "14.65444" - ], - [ - "-16.03945", - "17.73458" - ], - [ - "-16.91625", - "21.94542" - ], - [ - "-12.96271", - "27.92048" - ], - [ - "-11.51195", - "28.30375" - ], - [ - "-9.64097", - "30.16500" - ], - [ - "-8.53833", - "33.25055" - ], - [ - "-6.84306", - "34.01861" - ], - [ - "-5.91874", - "35.79065" - ], - [ - "-1.97972", - "35.07333" - ], - [ - "1.18250", - "36.51221" - ], - [ - "9.85868", - "37.32833" - ], - [ - "11.12667", - "35.24194" - ], - [ - "11.17430", - "33.21006" - ], - [ - "15.16583", - "32.39861" - ], - [ - "15.75430", - "31.38972" - ], - [ - "18.95750", - "30.27639" - ], - [ - "20.56763", - "32.56091" - ], - [ - "29.03500", - "30.82417" - ], - [ - "30.35545", - "31.50284" - ], - [ - "34.21666", - "31.32333" - ] - ] - ], - [ - [ - [ - "48.03140", - "-14.06341" - ], - [ - "49.94333", - "-13.03945" - ], - [ - "50.48277", - "-15.40583" - ], - [ - "49.36833", - "-18.35139" - ], - [ - "47.13305", - "-24.92806" - ], - [ - "44.01708", - "-24.98083" - ], - [ - "43.23888", - "-22.28250" - ], - [ - "44.48277", - "-19.96584" - ], - [ - "43.93139", - "-17.50056" - ], - [ - "44.87360", - "-16.21028" - ], - [ - "48.03140", - "-14.06341" - ] - ] - ] - ] - }, - "properties": { - "CONTINENT": "Africa" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - "-77.88972", - "7.22889" - ], - [ - "-77.36667", - "8.67500" - ], - [ - "-75.63432", - "9.44819" - ], - [ - "-74.86081", - "11.12549" - ], - [ - "-68.84368", - "11.44708" - ], - [ - "-68.11424", - "10.48493" - ], - [ - "-61.87959", - "10.72833" - ], - [ - "-61.61987", - "9.90528" - ], - [ - "-57.51919", - "6.27077" - ], - [ - "-52.97320", - "5.47305" - ], - [ - "-51.25931", - "4.15250" - ], - [ - "-49.90320", - "1.17444" - ], - [ - "-51.92751", - "-1.33486" - ], - [ - "-48.42722", - "-1.66028" - ], - [ - "-47.28556", - "-0.59917" - ], - [ - "-42.23584", - "-2.83778" - ], - [ - "-39.99875", - "-2.84653" - ], - [ - "-37.17445", - "-4.91861" - ], - [ - "-35.47973", - "-5.16611" - ], - [ - "-34.83129", - "-6.98180" - ], - [ - "-35.32751", - "-9.22889" - ], - [ - "-39.05709", - "-13.38028" - ], - [ - "-38.87195", - "-15.87417" - ], - [ - "-39.70403", - "-19.42361" - ], - [ - "-42.03445", - "-22.91917" - ], - [ - "-44.67521", - "-23.05570" - ], - [ - "-48.02612", - "-25.01500" - ], - [ - "-48.84251", - "-28.61778" - ], - [ - "-52.21764", - "-31.74500" - ], - [ - "-54.14077", - "-34.66466" - ], - [ - "-56.15834", - "-34.92722" - ], - [ - "-56.67834", - "-36.92361" - ], - [ - "-58.30112", - "-38.48500" - ], - [ - "-62.06875", - "-39.50848" - ], - [ - "-62.39001", - "-40.90195" - ], - [ - "-65.13014", - "-40.84417" - ], - [ - "-65.24945", - "-44.31306" - ], - [ - "-67.58435", - "-46.00030" - ], - [ - "-65.78979", - "-47.96584" - ], - [ - "-68.94112", - "-50.38806" - ], - [ - "-68.99014", - "-51.62445" - ], - [ - "-72.11501", - "-53.68764" - ], - [ - "-74.28924", - "-50.48049" - ], - [ - "-74.74139", - "-47.71146" - ], - [ - "-72.61389", - "-44.47278" - ], - [ - "-73.99432", - "-40.96695" - ], - [ - "-73.22404", - "-39.41688" - ], - [ - "-73.67709", - "-37.34729" - ], - [ - "-71.44667", - "-32.66500" - ], - [ - "-71.69585", - "-30.50667" - ], - [ - "-70.91389", - "-27.62445" - ], - [ - "-70.05334", - "-21.42565" - ], - [ - "-70.31202", - "-18.43750" - ], - [ - "-71.49424", - "-17.30223" - ], - [ - "-75.05139", - "-15.46597" - ], - [ - "-76.39480", - "-13.88417" - ], - [ - "-78.99459", - "-8.21965" - ], - [ - "-81.17473", - "-6.08667" - ], - [ - "-81.27640", - "-4.28083" - ], - [ - "-79.95632", - "-3.20778" - ], - [ - "-80.91279", - "-1.03653" - ], - [ - "-80.10084", - "0.77028" - ], - [ - "-78.88929", - "1.23837" - ], - [ - "-77.43445", - "4.03139" - ], - [ - "-77.88972", - "7.22889" - ] - ] - ] - }, - "properties": { - "CONTINENT": "South America" - } - }, - { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [ - "177.91779", - "-38.94280" - ], - [ - "175.95523", - "-41.25528" - ], - [ - "173.75165", - "-39.27000" - ], - [ - "174.94025", - "-38.10111" - ], - [ - "177.91779", - "-38.94280" - ] - ] - ], - [ - [ - [ - "171.18524", - "-44.93833" - ], - [ - "169.45801", - "-46.62333" - ], - [ - "166.47690", - "-45.80972" - ], - [ - "168.37233", - "-44.04056" - ], - [ - "171.15166", - "-42.56042" - ], - [ - "172.63025", - "-40.51056" - ], - [ - "174.23636", - "-41.83722" - ], - [ - "171.18524", - "-44.93833" - ] - ] - ] - ] - }, - "properties": { - "CONTINENT": "Oceania" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - "151.54025", - "-24.04583" - ], - [ - "153.18192", - "-25.94944" - ], - [ - "153.62419", - "-28.66104" - ], - [ - "152.52969", - "-32.40361" - ], - [ - "151.45456", - "-33.31681" - ], - [ - "149.97163", - "-37.52222" - ], - [ - "146.87357", - "-38.65166" - ], - [ - "143.54295", - "-38.85923" - ], - [ - "140.52997", - "-38.00028" - ], - [ - "138.09225", - "-34.13493" - ], - [ - "135.49586", - "-34.61708" - ], - [ - "134.18414", - "-32.48666" - ], - [ - "131.14859", - "-31.47403" - ], - [ - "125.97227", - "-32.26674" - ], - [ - "123.73499", - "-33.77972" - ], - [ - "120.00499", - "-33.92889" - ], - [ - "117.93414", - "-35.12534" - ], - [ - "115.00895", - "-34.26243" - ], - [ - "115.73998", - "-31.86806" - ], - [ - "113.64346", - "-26.65431" - ], - [ - "113.38971", - "-24.42944" - ], - [ - "114.03027", - "-21.84167" - ], - [ - "116.70749", - "-20.64917" - ], - [ - "121.02748", - "-19.59222" - ], - [ - "122.95623", - "-16.58681" - ], - [ - "126.85790", - "-13.75097" - ], - [ - "129.08942", - "-14.89944" - ], - [ - "130.57927", - "-12.40465" - ], - [ - "132.67198", - "-11.50813" - ], - [ - "135.23135", - "-12.29445" - ], - [ - "135.45135", - "-14.93278" - ], - [ - "136.76581", - "-15.90445" - ], - [ - "140.83330", - "-17.45194" - ], - [ - "141.66553", - "-15.02653" - ], - [ - "141.59412", - "-12.53167" - ], - [ - "142.78830", - "-11.08056" - ], - [ - "143.78220", - "-14.41333" - ], - [ - "145.31580", - "-14.94555" - ], - [ - "146.27762", - "-18.88701" - ], - [ - "147.43192", - "-19.41236" - ], - [ - "150.81912", - "-22.73194" - ], - [ - "151.54025", - "-24.04583" - ] - ] - ] - }, - "properties": { - "CONTINENT": "Australia" - } - } - ] -} \ No newline at end of file diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.test.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.test.ts deleted file mode 100644 index 5ad92d4e6d1d7..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.test.ts +++ /dev/null @@ -1,47 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getLayerList } from './map_config'; -import { mockLayerList } from './__mocks__/poly_layer_mock'; -import { LocationPoint } from './embedded_map'; -import { UptimeAppColors } from '../../../../../apps/uptime_app'; - -jest.mock('uuid', () => { - return { - v4: jest.fn(() => 'uuid.v4()'), - }; -}); - -describe('map_config', () => { - let upPoints: LocationPoint[]; - let downPoints: LocationPoint[]; - let colors: Pick; - - beforeEach(() => { - upPoints = [ - { name: 'US-EAST', location: { lat: '52.487239', lon: '13.399262' } }, - { location: { lat: '55.487239', lon: '13.399262' }, name: 'US-WEST' }, - { location: { lat: '54.487239', lon: '14.399262' }, name: 'Europe' }, - ]; - downPoints = [ - { location: { lat: '52.487239', lon: '13.399262' }, name: 'Asia' }, - { location: { lat: '55.487239', lon: '13.399262' }, name: 'APJ' }, - { location: { lat: '54.487239', lon: '14.399262' }, name: 'Canada' }, - ]; - colors = { - danger: '#BC261E', - gray: '#000', - }; - }); - - describe('#getLayerList', () => { - test('it returns the low poly layer', () => { - const layerList = getLayerList(upPoints, downPoints, colors); - expect(layerList).toStrictEqual(mockLayerList); - }); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts deleted file mode 100644 index 723eee6f14b80..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts +++ /dev/null @@ -1,175 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import lowPolyLayerFeatures from './low_poly_layer.json'; -import { LocationPoint } from './embedded_map'; -import { UptimeAppColors } from '../../../../../apps/uptime_app'; - -/** - * Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source, - * destination, and line layer for each of the provided indexPatterns - * - */ -export const getLayerList = ( - upPoints: LocationPoint[], - downPoints: LocationPoint[], - { danger }: Pick -) => { - return [getLowPolyLayer(), getDownPointsLayer(downPoints, danger), getUpPointsLayer(upPoints)]; -}; - -export const getLowPolyLayer = () => { - return { - id: 'low_poly_layer', - label: 'World countries', - minZoom: 0, - maxZoom: 24, - alpha: 1, - sourceDescriptor: { - id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854c', - type: 'GEOJSON_FILE', - __featureCollection: lowPolyLayerFeatures, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: '#cad3e4', - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 0, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', - }; -}; - -export const getDownPointsLayer = (downPoints: LocationPoint[], dangerColor: string) => { - const features = downPoints?.map((point) => ({ - type: 'feature', - id: point.name, - geometry: { - type: 'Point', - coordinates: [+point.location.lon, +point.location.lat], - }, - })); - return { - id: 'down_points', - label: 'Down Locations', - sourceDescriptor: { - type: 'GEOJSON_FILE', - __featureCollection: { - features, - type: 'FeatureCollection', - }, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: dangerColor, - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 2, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', - }; -}; - -export const getUpPointsLayer = (upPoints: LocationPoint[]) => { - const features = upPoints?.map((point) => ({ - type: 'feature', - id: point.name, - geometry: { - type: 'Point', - coordinates: [+point.location.lon, +point.location.lat], - }, - })); - return { - id: 'up_points', - label: 'Up Locations', - sourceDescriptor: { - type: 'GEOJSON_FILE', - __featureCollection: { - features, - type: 'FeatureCollection', - }, - }, - visible: true, - style: { - type: 'VECTOR', - properties: { - fillColor: { - type: 'STATIC', - options: { - color: '#98A2B2', - }, - }, - lineColor: { - type: 'STATIC', - options: { - color: '#fff', - }, - }, - lineWidth: { - type: 'STATIC', - options: { - size: 2, - }, - }, - iconSize: { - type: 'STATIC', - options: { - size: 6, - }, - }, - }, - }, - type: 'VECTOR', - }; -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx deleted file mode 100644 index c03ed94f8c544..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx +++ /dev/null @@ -1,91 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { i18n } from '@kbn/i18n'; -import React, { useContext } from 'react'; -import { useSelector } from 'react-redux'; -import { - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiOutsideClickDetector, - EuiPopoverTitle, -} from '@elastic/eui'; -import { TagLabel } from '../../availability_reporting'; -import { UptimeThemeContext } from '../../../../../contexts'; -import { AppState } from '../../../../../state'; -import { monitorLocationsSelector } from '../../../../../state/selectors'; -import { useMonitorId } from '../../../../../hooks'; -import { MonitorLocation } from '../../../../../../common/runtime_types/monitor'; -import type { RenderTooltipContentParams } from '../../../../../../../maps/public'; -import { formatAvailabilityValue } from '../../availability_reporting/availability_reporting'; -import { LastCheckLabel } from '../../translations'; - -type MapToolTipProps = Partial; - -export const MapToolTipComponent = ({ closeTooltip, features = [] }: MapToolTipProps) => { - const { id: featureId, layerId } = features[0] ?? {}; - const locationName = featureId?.toString(); - const { - colors: { gray, danger }, - } = useContext(UptimeThemeContext); - - const monitorId = useMonitorId(); - - const monitorLocations = useSelector((state: AppState) => - monitorLocationsSelector(state, monitorId) - ); - if (!locationName || !monitorLocations?.locations) { - return null; - } - const { - timestamp, - up_history: ups, - down_history: downs, - }: MonitorLocation = monitorLocations.locations!.find( - ({ geo }: MonitorLocation) => geo.name === locationName - )!; - - const availability = (ups / (ups + downs)) * 100; - - return ( - { - if (closeTooltip != null) { - closeTooltip(); - } - }} - > - <> - - {layerId === 'up_points' ? ( - - ) : ( - - )} - - - Availability - - {i18n.translate('xpack.uptime.mapToolTip.AvailabilityStat.title', { - defaultMessage: '{value} %', - values: { value: formatAvailabilityValue(availability) }, - description: 'A percentage value like 23.5%', - })} - - {LastCheckLabel} - - {moment(timestamp).fromNow()} - - - - - ); -}; - -export const MapToolTip = React.memo(MapToolTipComponent); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.test.tsx deleted file mode 100644 index 9818fc164193c..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.test.tsx +++ /dev/null @@ -1,34 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; -import { LocationMap } from './location_map'; -import { LocationPoint } from './embeddables/embedded_map'; - -// Note For shallow test, we need absolute time strings -describe('LocationMap component', () => { - let upPoints: LocationPoint[]; - - beforeEach(() => { - upPoints = [ - { - name: 'New York', - location: { lat: '40.730610', lon: ' -73.935242' }, - }, - { - name: 'Tokyo', - location: { lat: '52.487448', lon: ' 13.394798' }, - }, - ]; - }); - - it('renders correctly against snapshot', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx deleted file mode 100644 index 5a912a44b7c9a..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { EmbeddedMap, LocationPoint } from './embeddables/embedded_map'; - -// These height/width values are used to make sure map is in center of panel -// And to make sure, it doesn't take too much space -const MapPanel = styled.div` - height: 240px; - width: 520px; - margin-right: 65px; - @media (max-width: 574px) { - height: 250px; - width: 100%; - } -`; - -interface Props { - upPoints: LocationPoint[]; - downPoints: LocationPoint[]; -} - -export const LocationMap = ({ upPoints, downPoints }: Props) => { - return ( - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.test.tsx deleted file mode 100644 index dad7a61e74999..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.test.tsx +++ /dev/null @@ -1,22 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { renderWithIntl, shallowWithIntl } from '@kbn/test/jest'; -import { LocationMissingWarning } from './location_missing'; - -describe('LocationMissingWarning component', () => { - it('shallow render correctly against snapshot', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders correctly against snapshot', () => { - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx deleted file mode 100644 index 7b03f516decad..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx +++ /dev/null @@ -1,79 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; -import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiSpacer, - EuiText, - EuiCode, -} from '@elastic/eui'; -import styled from 'styled-components'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { LocationLink } from '../../../common/location_link'; - -const EuiPopoverRight = styled(EuiFlexItem)` - margin-left: auto; - margin-bottom: 3px; - margin-right: 5px; -`; - -export const LocationMissingWarning = () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const togglePopover = () => { - setIsPopoverOpen(!isPopoverOpen); - }; - - const button = ( - - - - ); - - return ( - - - - - observer.geo.?? }} - /> - - - - - - - - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index 17b3354b666c4..592fd56984707 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -84,6 +84,7 @@ function getIndexActionParams(): IndexActionParams { observerLocation: '{{state.observerLocation}}', }, ], + indexOverride: null, }; } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 90306466a9753..b7df493a1036a 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -34,6 +34,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/case_api_integration/basic/config.ts'), require.resolve('../test/apm_api_integration/basic/config.ts'), require.resolve('../test/apm_api_integration/trial/config.ts'), + require.resolve('../test/apm_api_integration/rules/config.ts'), require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'), require.resolve('../test/detection_engine_api_integration/basic/config.ts'), require.resolve('../test/lists_api_integration/security_and_spaces/config.ts'), @@ -73,7 +74,8 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/reporting_and_security.config.ts'), require.resolve('../test/reporting_api_integration/reporting_without_security.config.ts'), require.resolve('../test/security_solution_endpoint_api_int/config.ts'), - require.resolve('../test/fleet_api_integration/config.ts'), + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 + // require.resolve('../test/fleet_api_integration/config.ts'), require.resolve('../test/search_sessions_integration/config.ts'), require.resolve('../test/saved_object_tagging/api_integration/security_and_spaces/config.ts'), require.resolve('../test/saved_object_tagging/api_integration/tagging_api/config.ts'), diff --git a/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts b/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts index 1761c44813430..deb91f6b9b1ef 100644 --- a/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts +++ b/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts @@ -97,6 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickCreateDashboardPrompt(); await ml.dashboardEmbeddables.assertDashboardIsEmpty(); await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.ensureAddPanelIsShowing(); await dashboardAddPanel.clickAddNewEmbeddableLink('ml_anomaly_charts'); await ml.dashboardJobSelectionTable.assertJobSelectionTableExists(); await a11y.testAppSnapshot(); diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 032186b2e90ec..a2f0e835c0b3e 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -18,13 +18,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const toasts = getService('toasts'); - describe('Kibana spaces page meets a11y validations', () => { + // flaky + // https://github.com/elastic/kibana/issues/77933 + // https://github.com/elastic/kibana/issues/96625 + describe.skip('Kibana spaces page meets a11y validations', () => { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.common.navigateToApp('home'); }); - it('a11y test for manage spaces menu from top nav on Kibana home', async () => { + it.skip('a11y test for manage spaces menu from top nav on Kibana home', async () => { await PageObjects.spaceSelector.openSpacesNav(); await retry.waitFor( 'Manage spaces option visible', @@ -33,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('a11y test for manage spaces page', async () => { + it.skip('a11y test for manage spaces page', async () => { await PageObjects.spaceSelector.clickManageSpaces(); await PageObjects.header.waitUntilLoadingHasFinished(); await toasts.dismissAllToasts(); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index beb639eb46334..6a0ab54087844 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -20,6 +20,7 @@ interface CreateTestConfigOptions { enableActionsProxy: boolean; rejectUnauthorized?: boolean; publicBaseUrl?: boolean; + preconfiguredAlertHistoryEsIndex?: boolean; } // test.not-enabled is specifically not enabled @@ -47,6 +48,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) disabledPlugins = [], ssl = false, rejectUnauthorized = true, + preconfiguredAlertHistoryEsIndex = false, } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -119,6 +121,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ...actionsProxyUrl, '--xpack.eventLog.logEntries=true', + `--xpack.actions.preconfiguredAlertHistoryEsIndex=${preconfiguredAlertHistoryEsIndex}`, `--xpack.actions.preconfigured=${JSON.stringify({ 'my-slack1': { actionTypeId: '.slack', diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index c397a2659557f..49d5f52869b89 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,4 +13,5 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, rejectUnauthorized: false, + preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/preconfigured_alert_history_connector.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/preconfigured_alert_history_connector.ts new file mode 100644 index 0000000000000..cf8a0f99d4394 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/preconfigured_alert_history_connector.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getTestAlertData, ObjectRemover } from '../../../../common/lib'; +import { AlertHistoryDefaultIndexName } from '../../../../../../plugins/actions/common'; + +const ALERT_HISTORY_OVERRIDE_INDEX = 'kibana-alert-history-not-the-default'; + +// eslint-disable-next-line import/no-default-export +export default function preconfiguredAlertHistoryConnectorTests({ + getService, +}: FtrProviderContext) { + const es = getService('legacyEs'); + const supertest = getService('supertest'); + const retry = getService('retry'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + describe('preconfigured alert history connector', () => { + const spaceId = 'default'; + const ruleTypeId = 'test.patternFiring'; + const alertId = 'instance'; + + function getTestData(params = {}) { + return getTestAlertData({ + rule_type_id: ruleTypeId, + schedule: { interval: '1s' }, + params: { + pattern: { [alertId]: new Array(100).fill(true) }, + }, + actions: [ + { + group: 'default', + id: 'preconfigured-alert-history-es-index', + params, + }, + ], + }); + } + + const objectRemover = new ObjectRemover(supertest); + beforeEach(() => { + esDeleteAllIndices(AlertHistoryDefaultIndexName); + esDeleteAllIndices(ALERT_HISTORY_OVERRIDE_INDEX); + }); + after(() => objectRemover.removeAll()); + + it('should index document with preconfigured schema', async () => { + const testRuleData = getTestData({ + documents: [{}], + }); + const response = await supertest + .post(`/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(testRuleData); + expect(response.status).to.eql(200); + objectRemover.add(spaceId, response.body.id, 'rule', 'alerting'); + + // Wait for alert to be active + await waitForStatus(response.body.id, new Set(['active'])); + + await retry.try(async () => { + const result = await es.search({ + index: AlertHistoryDefaultIndexName, + }); + const indexedItems = result.hits.hits; + expect(indexedItems.length).to.eql(1); + + const indexedDoc = indexedItems[0]._source; + + const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(indexedDoc['@timestamp']).to.match(timestampPattern); + expect(indexedDoc.tags).to.eql(testRuleData.tags); + expect(indexedDoc.rule.name).to.eql(testRuleData.name); + expect(indexedDoc.rule.params[ruleTypeId.replace('.', '__')]).to.eql(testRuleData.params); + expect(indexedDoc.rule.space).to.eql(spaceId); + expect(indexedDoc.rule.type).to.eql(ruleTypeId); + expect(indexedDoc.kibana.alert.id).to.eql(alertId); + expect(indexedDoc.kibana.alert.context[ruleTypeId.replace('.', '__')] != null).to.eql(true); + expect(indexedDoc.kibana.alert.actionGroup).to.eql('default'); + expect(indexedDoc.kibana.alert.actionGroupName).to.eql('Default'); + }); + }); + + it('should index document with preconfigured schema when indexOverride is defined', async () => { + const testRuleData = getTestData({ + documents: [{}], + indexOverride: ALERT_HISTORY_OVERRIDE_INDEX, + }); + const response = await supertest + .post(`/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(testRuleData); + expect(response.status).to.eql(200); + objectRemover.add(spaceId, response.body.id, 'rule', 'alerting'); + + // Wait for alert to be active + await waitForStatus(response.body.id, new Set(['active'])); + + await retry.try(async () => { + const result = await es.search({ + index: ALERT_HISTORY_OVERRIDE_INDEX, + }); + const indexedItems = result.hits.hits; + expect(indexedItems.length).to.eql(1); + + const indexedDoc = indexedItems[0]._source; + + const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(indexedDoc['@timestamp']).to.match(timestampPattern); + expect(indexedDoc.tags).to.eql(testRuleData.tags); + expect(indexedDoc.rule.name).to.eql(testRuleData.name); + expect(indexedDoc.rule.params[ruleTypeId.replace('.', '__')]).to.eql(testRuleData.params); + expect(indexedDoc.rule.space).to.eql(spaceId); + expect(indexedDoc.rule.type).to.eql(ruleTypeId); + expect(indexedDoc.kibana.alert.id).to.eql(alertId); + expect(indexedDoc.kibana.alert.context[ruleTypeId.replace('.', '__')] != null).to.eql(true); + expect(indexedDoc.kibana.alert.actionGroup).to.eql('default'); + expect(indexedDoc.kibana.alert.actionGroupName).to.eql('Default'); + }); + }); + }); + + const WaitForStatusIncrement = 500; + + async function waitForStatus( + id: string, + statuses: Set, + waitMillis: number = 10000 + ): Promise> { + if (waitMillis < 0) { + expect().fail(`waiting for alert ${id} statuses ${Array.from(statuses)} timed out`); + } + + const response = await supertest.get(`/api/alerts/alert/${id}`); + expect(response.status).to.eql(200); + + const { executionStatus } = response.body || {}; + const { status } = executionStatus || {}; + + const message = `waitForStatus(${Array.from(statuses)}): got ${JSON.stringify( + executionStatus + )}`; + + if (statuses.has(status)) { + return executionStatus; + } + + // eslint-disable-next-line no-console + console.log(`${message}, retrying`); + + await delay(WaitForStatusIncrement); + return await waitForStatus(id, statuses, waitMillis - WaitForStatusIncrement); + } + + async function delay(millis: number): Promise { + await new Promise((resolve) => setTimeout(resolve, millis)); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 531df9d4ed19f..08241f2ad8e22 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -36,6 +36,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`).expect(200, [ + { + id: 'preconfigured-alert-history-es-index', + name: 'Alert history Elasticsearch index', + connector_type_id: '.index', + is_preconfigured: true, + referenced_by_count: 0, + }, { id: createdAction.id, is_preconfigured: false, @@ -95,6 +102,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`).expect(200, [ + { + id: 'preconfigured-alert-history-es-index', + name: 'Alert history Elasticsearch index', + connector_type_id: '.index', + is_preconfigured: true, + referenced_by_count: 0, + }, { id: 'preconfigured-es-index-action', is_preconfigured: true, @@ -145,6 +159,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`).expect(200, [ + { + id: 'preconfigured-alert-history-es-index', + name: 'Alert history Elasticsearch index', + actionTypeId: '.index', + isPreconfigured: true, + referencedByCount: 0, + }, { id: createdAction.id, isPreconfigured: false, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index d5056508e5de9..43f442c131626 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -23,6 +23,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./execute')); loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/webhook')); + loadTestFile(require.resolve('./builtin_action_types/preconfigured_alert_history_connector')); loadTestFile(require.resolve('./type_not_enabled')); // note that this test will destroy existing spaces diff --git a/x-pack/test/apm_api_integration/common/apm_api_supertest.ts b/x-pack/test/apm_api_integration/common/apm_api_supertest.ts index 542982778dfff..ed104a6fdf064 100644 --- a/x-pack/test/apm_api_integration/common/apm_api_supertest.ts +++ b/x-pack/test/apm_api_integration/common/apm_api_supertest.ts @@ -8,24 +8,25 @@ import { format } from 'url'; import supertest from 'supertest'; import request from 'superagent'; -import { MaybeParams } from '../../../plugins/apm/server/routes/typings'; import { parseEndpoint } from '../../../plugins/apm/common/apm_api/parse_endpoint'; -import { APMAPI } from '../../../plugins/apm/server/routes/create_apm_api'; -import type { APIReturnType } from '../../../plugins/apm/public/services/rest/createCallApmApi'; +import type { + APIReturnType, + APIEndpoint, + APIClientRequestParamsOf, +} from '../../../plugins/apm/public/services/rest/createCallApmApi'; export function createApmApiSupertest(st: supertest.SuperTest) { - return async ( + return async ( options: { - endpoint: TPath; - } & MaybeParams + endpoint: TEndpoint; + } & APIClientRequestParamsOf & { params?: { query?: { _inspect?: boolean } } } ): Promise<{ status: number; - body: APIReturnType; + body: APIReturnType; }> => { const { endpoint } = options; - // @ts-expect-error - const params = 'params' in options ? options.params : {}; + const params = 'params' in options ? (options.params as Record) : {}; const { method, pathname } = parseEndpoint(endpoint, params?.path); const url = format({ pathname, query: params?.query }); diff --git a/x-pack/test/apm_api_integration/common/config.ts b/x-pack/test/apm_api_integration/common/config.ts index 04ce83323ee66..732f14d2a7bc8 100644 --- a/x-pack/test/apm_api_integration/common/config.ts +++ b/x-pack/test/apm_api_integration/common/config.ts @@ -18,6 +18,7 @@ import { registry } from './registry'; interface Config { name: APMFtrConfigName; license: 'basic' | 'trial'; + kibanaConfig?: Record; } const supertestAsApmUser = (kibanaServer: UrlObject, apmUser: ApmUser) => async ( @@ -37,7 +38,7 @@ const supertestAsApmUser = (kibanaServer: UrlObject, apmUser: ApmUser) => async }; export function createTestConfig(config: Config) { - const { license, name } = config; + const { license, name, kibanaConfig } = config; return async ({ readConfigFile }: FtrConfigProviderContext) => { const xPackAPITestsConfig = await readConfigFile( @@ -79,7 +80,15 @@ export function createTestConfig(config: Config) { ...xPackAPITestsConfig.get('esTestCluster'), license, }, - kbnTestServer: xPackAPITestsConfig.get('kbnTestServer'), + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + ...(kibanaConfig + ? Object.entries(kibanaConfig).map(([key, value]) => `--${key}=${value}`) + : []), + ], + }, }; }; } diff --git a/x-pack/test/apm_api_integration/configs/index.ts b/x-pack/test/apm_api_integration/configs/index.ts index 4eeb57e3c86c4..91437a2d22e27 100644 --- a/x-pack/test/apm_api_integration/configs/index.ts +++ b/x-pack/test/apm_api_integration/configs/index.ts @@ -15,6 +15,12 @@ const apmFtrConfigs = { trial: { license: 'trial' as const, }, + rules: { + license: 'trial' as const, + kibanaConfig: { + 'xpack.ruleRegistry.writeEnabled': 'true', + }, + }, }; export type APMFtrConfigName = keyof typeof apmFtrConfigs; diff --git a/x-pack/test/apm_api_integration/rules/config.ts b/x-pack/test/apm_api_integration/rules/config.ts new file mode 100644 index 0000000000000..9830d516eb80a --- /dev/null +++ b/x-pack/test/apm_api_integration/rules/config.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { configs } from '../configs'; + +export default configs.rules; diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts new file mode 100644 index 0000000000000..97026d126d2a1 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -0,0 +1,387 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { get, merge, omit } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; + +interface Alert { + schedule: { + interval: string; + }; + updatedAt: string; + executionStatus: { + lastExecutionDate: string; + status: string; + }; + updatedBy: string; + id: string; + params: Record; + scheduledTaskId: string; +} + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertestAsApmWriteUser'); + const es = getService('es'); + + const MAX_POLLS = 5; + const BULK_INDEX_DELAY = 1000; + const INDEXING_DELAY = 5000; + + const ALERTS_INDEX_TARGET = '.kibana-alerts-*-apm*'; + const APM_TRANSACTION_INDEX_NAME = 'apm-8.0.0-transaction'; + + const createTransactionEvent = (override: Record) => { + const now = Date.now(); + + const time = now - INDEXING_DELAY; + + return merge( + { + '@timestamp': new Date(time).toISOString(), + service: { + name: 'opbeans-go', + }, + event: { + outcome: 'success', + }, + transaction: { + duration: { + us: 1000000, + }, + type: 'request', + }, + processor: { + event: 'transaction', + }, + observer: { + version_major: 7, + }, + }, + override + ); + }; + + async function waitUntilNextExecution( + alert: Alert, + intervalInSeconds: number = 1, + count: number = 0 + ): Promise { + await new Promise((resolve) => { + setTimeout(resolve, intervalInSeconds * 1000); + }); + + const { body, status } = await supertest + .get(`/api/alerts/alert/${alert.id}`) + .set('kbn-xsrf', 'foo'); + + if (status >= 300) { + const error = new Error('Error getting alert'); + Object.assign(error, { response: { body, status } }); + throw error; + } + + const nextAlert = body as Alert; + + if (nextAlert.executionStatus.lastExecutionDate !== alert.executionStatus.lastExecutionDate) { + await new Promise((resolve) => { + setTimeout(resolve, BULK_INDEX_DELAY); + }); + await es.indices.refresh({ + index: ALERTS_INDEX_TARGET, + }); + + return nextAlert; + } + + if (count >= MAX_POLLS) { + throw new Error('Maximum number of polls exceeded'); + } + + return waitUntilNextExecution(alert, intervalInSeconds, count + 1); + } + + registry.when('Rule registry with write enabled', { config: 'rules', archives: [] }, () => { + it('bootstraps the apm alert indices', async () => { + const { body } = await es.indices.get({ + index: ALERTS_INDEX_TARGET, + expand_wildcards: 'open', + allow_no_indices: false, + }); + + const indices = Object.entries(body).map(([indexName, index]) => { + return { + indexName, + index, + }; + }); + + const indexNames = indices.map((index) => index.indexName); + + const apmIndex = indices[0]; + + // make sure it only creates one index + expect(indices.length).to.be(1); + + const apmIndexName = apmIndex.indexName; + + expect(apmIndexName.split('-').includes('observability')).to.be(true); + expect(apmIndexName.split('-').includes('apm')).to.be(true); + + expect(indexNames[0].startsWith('.kibana-alerts-observability-apm')).to.be(true); + + expect(get(apmIndex, 'index.mappings.properties.service.properties.environment.type')).to.be( + 'keyword' + ); + }); + + describe('when creating a rule', () => { + let createResponse: { + alert: Alert; + status: number; + }; + + before(async () => { + await es.indices.create({ + index: APM_TRANSACTION_INDEX_NAME, + body: { + mappings: { + dynamic: 'strict', + properties: { + event: { + properties: { + outcome: { + type: 'keyword', + }, + }, + }, + processor: { + properties: { + event: { + type: 'keyword', + }, + }, + }, + observer: { + properties: { + version_major: { + type: 'byte', + }, + }, + }, + service: { + properties: { + name: { + type: 'keyword', + }, + environment: { + type: 'keyword', + }, + }, + }, + transaction: { + properties: { + type: { + type: 'keyword', + }, + duration: { + properties: { + us: { + type: 'long', + }, + }, + }, + }, + }, + '@timestamp': { + type: 'date', + }, + }, + }, + }, + }); + + const body = { + params: { + threshold: 30, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + environment: 'ENVIRONMENT_ALL', + serviceName: 'opbeans-go', + }, + consumer: 'apm', + alertTypeId: 'apm.transaction_error_rate', + schedule: { interval: '5s' }, + actions: [], + tags: ['apm', 'service.name:opbeans-go'], + notifyWhen: 'onActionGroupChange', + name: 'Transaction error rate threshold | opbeans-go', + }; + + const { body: response, status } = await supertest + .post('/api/alerts/alert') + .send(body) + .set('kbn-xsrf', 'foo'); + + createResponse = { + alert: response, + status, + }; + }); + + after(async () => { + if (createResponse.alert) { + const { body, status } = await supertest + .delete(`/api/alerts/alert/${createResponse.alert.id}`) + .set('kbn-xsrf', 'foo'); + + if (status >= 300) { + const error = new Error('Error deleting alert'); + Object.assign(error, { response: { body, status } }); + throw error; + } + } + + await es.deleteByQuery({ + index: ALERTS_INDEX_TARGET, + body: { + query: { + match_all: {}, + }, + }, + refresh: true, + }); + + await es.indices.delete({ + index: APM_TRANSACTION_INDEX_NAME, + }); + }); + + it('writes alerts data to the alert indices', async () => { + expect(createResponse.status).to.be.below(299); + + expect(createResponse.alert).not.to.be(undefined); + + let alert = await waitUntilNextExecution(createResponse.alert); + + const beforeDataResponse = await es.search({ + index: ALERTS_INDEX_TARGET, + body: { + query: { + match_all: {}, + }, + }, + size: 1, + }); + + expect(beforeDataResponse.body.hits.hits.length).to.be(0); + + await es.index({ + index: APM_TRANSACTION_INDEX_NAME, + body: createTransactionEvent({ + event: { + outcome: 'success', + }, + }), + refresh: true, + }); + + alert = await waitUntilNextExecution(alert); + + const afterInitialDataResponse = await es.search({ + index: ALERTS_INDEX_TARGET, + body: { + query: { + match_all: {}, + }, + }, + size: 1, + }); + + expect(afterInitialDataResponse.body.hits.hits.length).to.be(0); + + await es.index({ + index: APM_TRANSACTION_INDEX_NAME, + body: createTransactionEvent({ + event: { + outcome: 'failure', + }, + }), + refresh: true, + }); + + alert = await waitUntilNextExecution(alert); + + const afterViolatingDataResponse = await es.search({ + index: ALERTS_INDEX_TARGET, + body: { + query: { + match_all: {}, + }, + }, + size: 1, + }); + + expect(afterViolatingDataResponse.body.hits.hits.length).to.be(1); + + const alertEvent = afterViolatingDataResponse.body.hits.hits[0]._source as Record< + string, + any + >; + + const toCompare = omit( + alertEvent, + '@timestamp', + 'kibana.rac.alert.start', + 'kibana.rac.alert.uuid', + 'rule.uuid' + ); + + expectSnapshot(toCompare).toMatchInline(` + Object { + "event.action": "open", + "event.kind": "state", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "apm.transaction_error_rate_opbeans-go_request", + "kibana.rac.alert.status": "open", + "kibana.rac.producer": "apm", + "rule.category": "Transaction error rate threshold", + "rule.id": "apm.transaction_error_rate", + "rule.name": "Transaction error rate threshold | opbeans-go", + "service.name": "opbeans-go", + "tags": Array [ + "apm", + "service.name:opbeans-go", + ], + "transaction.type": "request", + } + `); + }); + }); + }); + + registry.when('Rule registry with write not enabled', { config: 'basic', archives: [] }, () => { + it('does not bootstrap the apm rule indices', async () => { + const errorOrUndefined = await es.indices + .get({ + index: ALERTS_INDEX_TARGET, + expand_wildcards: 'open', + allow_no_indices: false, + }) + .then(() => {}) + .catch((error) => { + return error.toString(); + }); + + expect(errorOrUndefined).not.to.be(undefined); + + expect(errorOrUndefined).to.be(`ResponseError: index_not_found_exception`); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index 7c69d5b996cea..53ec61b8d9b61 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -24,6 +24,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./alerts/chart_preview')); }); + describe('alerts/rule_registry', function () { + loadTestFile(require.resolve('./alerts/rule_registry')); + }); + describe('correlations/latency_slow_transactions', function () { loadTestFile(require.resolve('./correlations/latency_slow_transactions')); }); diff --git a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts index aae2e38e8ec8e..4f65808de820e 100644 --- a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts +++ b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts @@ -81,7 +81,6 @@ export default function customLinksTests({ getService }: FtrProviderContext) { it('for agent configs', async () => { const { status, body } = await supertestRead({ endpoint: 'GET /api/apm/settings/agent-configuration', - // @ts-expect-error params: { query: { _inspect: true, diff --git a/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts b/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts index aac92685a3c34..baa95eb56a126 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; import archives from '../../common/fixtures/es_archiver/archives_metadata'; import { registry } from '../../common/registry'; import { createApmApiSupertest } from '../../common/apm_api_supertest'; +import { LatencyAggregationType } from '../../../../plugins/apm/common/latency_aggregation_types'; export default function ApiTest({ getService }: FtrProviderContext) { const apmApiSupertest = createApmApiSupertest(getService('supertest')); @@ -31,7 +32,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-java' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', @@ -61,7 +62,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-java' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', @@ -130,7 +131,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-ruby' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', diff --git a/x-pack/test/fleet_api_integration/apis/enrollment_api_keys/crud.ts b/x-pack/test/fleet_api_integration/apis/enrollment_api_keys/crud.ts index 2569d9aef4b5b..d9946bb174f5d 100644 --- a/x-pack/test/fleet_api_integration/apis/enrollment_api_keys/crud.ts +++ b/x-pack/test/fleet_api_integration/apis/enrollment_api_keys/crud.ts @@ -115,6 +115,28 @@ export default function (providerContext: FtrProviderContext) { expect(apiResponse.item).to.have.keys('id', 'api_key', 'api_key_id', 'name', 'policy_id'); }); + it('should create an ES ApiKey with metadata', async () => { + const { body: apiResponse } = await supertest + .post(`/api/fleet/enrollment-api-keys`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy1', + }) + .expect(200); + + const { body: apiKeyRes } = await es.security.getApiKey({ + id: apiResponse.item.api_key_id, + }); + + // @ts-expect-error Metadata not yet in the client type + expect(apiKeyRes.api_keys[0].metadata).eql({ + policy_id: 'policy1', + managed_by: 'fleet', + managed: true, + type: 'enroll', + }); + }); + it('should create an ES ApiKey with limited privileges', async () => { const { body: apiResponse } = await supertest .post(`/api/fleet/enrollment-api-keys`) @@ -162,33 +184,6 @@ export default function (providerContext: FtrProviderContext) { }, }); }); - - describe('It should handle error when the Fleet user is invalid', () => { - before(async () => {}); - after(async () => { - await getService('supertest') - .post(`/api/fleet/agents/setup`) - .set('kbn-xsrf', 'xxx') - .send({ forceRecreate: true }); - }); - - it('should not allow to create an enrollment api key if the Fleet admin user is invalid', async () => { - await es.security.changePassword({ - username: 'fleet_enroll', - body: { - password: Buffer.from((Math.random() * 10000000).toString()).toString('base64'), - }, - }); - const res = await supertest - .post(`/api/fleet/enrollment-api-keys`) - .set('kbn-xsrf', 'xxx') - .send({ - policy_id: 'policy1', - }) - .expect(400); - expect(res.body.message).match(/Fleet Admin user is invalid/); - }); - }); }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/list.ts b/x-pack/test/fleet_api_integration/apis/epm/list.ts index 0a7002764a54c..5a991e52bdba4 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/list.ts @@ -19,8 +19,7 @@ export default function (providerContext: FtrProviderContext) { // because `this` has to point to the Mocha context // see https://mochajs.org/#arrow-functions - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('EPM - list', async function () { + describe('EPM - list', async function () { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('fleet/empty_fleet_server'); diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index a82ed3f8cf22d..c9709475d182d 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -15,8 +15,7 @@ export default function (providerContext: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('fleet_setup', () => { + describe('fleet_setup', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts b/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts index 1619765946916..f7bfd7f7a4c62 100644 --- a/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts +++ b/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts @@ -88,6 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickCreateDashboardPrompt(); await ml.dashboardEmbeddables.assertDashboardIsEmpty(); await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.ensureAddPanelIsShowing(); await dashboardAddPanel.clickAddNewEmbeddableLink('ml_anomaly_charts'); await ml.dashboardJobSelectionTable.assertJobSelectionTableExists(); }); diff --git a/x-pack/test/functional/apps/rollup_job/tsvb.js b/x-pack/test/functional/apps/rollup_job/tsvb.js index d0c7c86d6d5c3..891805acb3256 100644 --- a/x-pack/test/functional/apps/rollup_job/tsvb.js +++ b/x-pack/test/functional/apps/rollup_job/tsvb.js @@ -83,6 +83,7 @@ export default function ({ getService, getPageObjects }) { ); await PageObjects.visualBuilder.clickPanelOptions('metric'); await PageObjects.visualBuilder.setIndexPatternValue(rollupTargetIndexName, false); + await PageObjects.visualBuilder.selectIndexPatternTimeField('@timestamp'); await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); await PageObjects.visualBuilder.setIntervalValue('1d'); await PageObjects.visualBuilder.setDropLastBucket(false); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 5dd1890e240a4..91a349e1bf44a 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -66,6 +66,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, + `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ 'my-slack1': { actionTypeId: '.slack', diff --git a/x-pack/test/load/runner.ts b/x-pack/test/load/runner.ts index 28e6bc93c4084..3e7a4817eeef1 100644 --- a/x-pack/test/load/runner.ts +++ b/x-pack/test/load/runner.ts @@ -10,6 +10,7 @@ import { resolve } from 'path'; import { REPO_ROOT } from '@kbn/utils'; import Fs from 'fs'; import { createFlagError } from '@kbn/dev-utils'; +import { delay } from 'bluebird'; import { FtrProviderContext } from './../functional/ftr_provider_context'; const baseSimulationPath = 'src/test/scala/org/kibanaLoadTest/simulation'; @@ -51,28 +52,13 @@ export async function GatlingTestRunner({ getService }: FtrProviderContext) { const log = getService('log'); await withProcRunner(log, async (procs) => { - await procs.run('mvn: clean compile', { - cmd: 'mvn', - args: [ - '-Dmaven.wagon.http.retryHandler.count=3', - '-Dmaven.test.failure.ignore=true', - '-q', - 'clean', - 'compile', - ], - cwd: gatlingProjectRootPath, - env: { - ...process.env, - }, - wait: true, - }); - for (const simulationClass of simulationClasses) { + for (let i = 0; i < simulationClasses.length; i++) { await procs.run('gatling: test', { cmd: 'mvn', args: [ 'gatling:test', '-q', - `-Dgatling.simulationClass=${simulationPackage}.${simulationClass}`, + `-Dgatling.simulationClass=${simulationPackage}.${simulationClasses[i]}`, ], cwd: gatlingProjectRootPath, env: { @@ -80,6 +66,10 @@ export async function GatlingTestRunner({ getService }: FtrProviderContext) { }, wait: true, }); + // wait a minute between simulations, skip for the last one + if (i < simulationClasses.length - 1) { + await delay(60 * 1000); + } } }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts index 8ee028ae3f56b..14e08992de9b4 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts @@ -19,7 +19,7 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getSupertestWithoutAuth(providerContext); let agentAccessAPIKey: string; - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 + // flaky https://github.com/elastic/kibana/issues/96515 describe.skip('artifact download', () => { const esArchiverSnapshots = [ 'endpoint/artifacts/fleet_artifacts', diff --git a/yarn.lock b/yarn.lock index bb5d9ff8c23aa..0e6427d2e265e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2680,6 +2680,10 @@ version "0.0.0" uid "" +"@kbn/io-ts-utils@link:packages/kbn-io-ts-utils": + version "0.0.0" + uid "" + "@kbn/legacy-logging@link:packages/kbn-legacy-logging": version "0.0.0" uid "" @@ -2712,6 +2716,10 @@ version "0.0.0" uid "" +"@kbn/server-route-repository@link:packages/kbn-server-route-repository": + version "0.0.0" + uid "" + "@kbn/std@link:packages/kbn-std": version "0.0.0" uid "" @@ -10942,11 +10950,6 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" -custom-event-polyfill@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-0.3.0.tgz#99807839be62edb446b645832e0d80ead6fa1888" - integrity sha1-mYB4Ob5i7bRGtkWDLg2A6tb6GIg= - cwise-compiler@^1.0.0, cwise-compiler@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/cwise-compiler/-/cwise-compiler-1.1.3.tgz#f4d667410e850d3a313a7d2db7b1e505bb034cc5"