diff --git a/NOTICE.txt b/NOTICE.txt index 2341a478cbda..4eec329b7a60 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 c1e727b1eac6..3827cb6e9aa7 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 691d7fb82f3b..0c40c2a8c4db 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.indexpatternselectprops.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternselectprops.md index 80f4832ba564..5cfd5e1bc992 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 2473c9cfdde8..cc0cb538be61 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 000000000000..b4431b9467b7 --- /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 4d75dda61d5c..521ceeb1e37f 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.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md index cfaad01c029e..259009c1c566 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 7c7f2a53aca9..193a2e5a24f3 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 cc50d3f01797..d384b9659dbc 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/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 7fd4dd5b8e56..413a59be3d42 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 000000000000..7440f5a9d26c --- /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 be4c3705bd8d..40fa872ff0fc 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 0911c3e86964..930f7710f9a0 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 5201444e6986..290dc1066256 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 1bdc9b9dea85..5e6a60f019be 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 a9de1888465f..02cb25078cc9 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 1c53fbd55ea4..11aa636e0d85 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 e4d2b53a2d8d..5d0242ae3195 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 20bbbcf874c0..c748d63484e2 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/settings/search-sessions-settings.asciidoc b/docs/settings/search-sessions-settings.asciidoc index cf64d08e4806..abd6a8f12b56 100644 --- a/docs/settings/search-sessions-settings.asciidoc +++ b/docs/settings/search-sessions-settings.asciidoc @@ -11,15 +11,33 @@ Configure the search session settings in your `kibana.yml` configuration file. [cols="2*<"] |=== a| `xpack.data_enhanced.` -`search.sessions.enabled` +`search.sessions.enabled` {ess-icon} | Set to `true` (default) to enable search sessions. a| `xpack.data_enhanced.` -`search.sessions.trackingInterval` -| The frequency for updating the state of a search session. The default is 10s. +`search.sessions.trackingInterval` {ess-icon} +| The frequency for updating the state of a search session. The default is `10s`. a| `xpack.data_enhanced.` -`search.sessions.defaultExpiration` +`search.sessions.pageSize` {ess-icon} +| How many search sessions {kib} processes at once while monitoring +session progress. The default is `100`. + +a| `xpack.data_enhanced.` +`search.sessions.notTouchedTimeout` {ess-icon} +| How long {kib} stores search results from unsaved sessions, +after the last search in the session completes. The default is `5m`. + +a| `xpack.data_enhanced.` +`search.sessions.notTouchedInProgressTimeout` {ess-icon} +| How long a search session can run after a user navigates away without saving a session. The default is `1m`. + +a| `xpack.data_enhanced.` +`search.sessions.maxUpdateRetries` {ess-icon} +| How many retries {kib} can perform while attempting to save a search session. The default is `3`. + +a| `xpack.data_enhanced.` +`search.sessions.defaultExpiration` {ess-icon} | How long search session results are stored before they are deleted. -Extending a search session resets the expiration by the same value. The default is 7d. +Extending a search session resets the expiration by the same value. The default is `7d`. |=== diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 643718b96165..90e813afad6f 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 80226e737e9c..e23dcbf298fd 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 ee8a28a86482..557404f24288 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 000000000000..35f9b19710cd 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 a4acc93310e5..805ae924a599 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 1dc1ae640e3b..000000000000 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 29efdd85c4df..000000000000 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 c882eeea42d6..000000000000 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 36f83f09f064..000000000000 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 000000000000..53540da7170e 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 000000000000..a48fdeaa6efa 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 000000000000..305b97017a9d 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 000000000000..8df26cf28ef1 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 6a5c4a83aa3a..71c5bd268a67 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 6324539c3c10..000000000000 --- 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 000000000000..63b83712e3e6 --- /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 a38bf699c1db..bea13c1ef49b 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 c87bf21e0e71..3bac445581ae 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 ffe1a10f0bfe..9bddca466546 100644 --- a/package.json +++ b/package.json @@ -208,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-ui-shared-deps/polyfills.js b/packages/kbn-ui-shared-deps/polyfills.js index abbf911cfc8f..a9ec32023f2b 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 d8818fe809cc..000000000000 --- 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 b179c998f112..baf8ed2a6164 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 474e8dd59b4c..f1006f93dbc2 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 a33625cc0841..c7a4721b7d2a 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/dev/dev_config.ts b/src/core/server/dev/dev_config.ts deleted file mode 100644 index 2fec778d8571..000000000000 --- a/src/core/server/dev/dev_config.ts +++ /dev/null @@ -1,16 +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 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'; - -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' }), -}; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index b34d7fec3dcb..45d11f9013fe 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 cc2919f7555c..4d2c45a9c84b 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 6cc94208fbcc..1ad155928899 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/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 805eccd1ee31..04d278513771 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 c46e5c5266f5..e6f815e058ce 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 297af560081b..3ce528e6ed89 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 4e278d5872a3..56e720d237c4 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 bb34d7ede453..09dfbb28a4e5 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 7d37dc83405b..77c9c6e391c0 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 2d3ff8f5fdba..62dac9831211 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 7580032b0dd8..c2566535916a 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 72d9cc409557..5620698a4753 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 2fa54d47445b..a6ea8da6ac6e 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 1cb04075dad7..757e0de6ecb4 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 70257d2a5e6c..d5947f8a18cc 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 99acbce8935c..24507a7e1305 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 fd97a3d3381a..3726e5d0c33e 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 f11e7f06b6ab..e1e7a8292d67 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 d77a2ea62bb9..37de8dc49d3c 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 d2683e248b7b..e86b64d135d5 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, @@ -428,7 +426,6 @@ export const search = { getNumberHistogramIntervalByDatatableColumn, getDateHistogramMetaDataByDatatableColumn, }, - getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse, tabifyGetColumns, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index db7f814a83f7..c4e54c64af13 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1560,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) @@ -1677,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; } @@ -1786,6 +1789,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) IP_RANGE = "ip_range", // (undocumented) + MISSING = "missing", + // (undocumented) MURMUR3 = "murmur3", // (undocumented) NESTED = "nested", @@ -2297,7 +2302,6 @@ export const search: { timeRange: import("../common").TimeRange | undefined; } | undefined; }; - getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; tabifyAggResponse: typeof tabifyAggResponse; tabifyGetColumns: typeof tabifyGetColumns; @@ -2433,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 // @@ -2711,21 +2715,20 @@ export interface WaitUntilNextSessionCompletesOptions { // 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:406:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:425:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" 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 +// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts 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 aa36323d11bc..04bdb7a69026 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 466cc8c3de0b..4e12f1166873 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 900a4ab7d7eb..0f660f87266f 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/kibana.json b/src/plugins/discover/kibana.json index 7db03f726e6f..6ea22001f5d8 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -12,7 +12,8 @@ "urlForwarding", "navigation", "uiActions", - "savedObjects" + "savedObjects", + "indexPatternFieldEditor" ], "optionalPlugins": ["home", "share", "usageCollection"], "requiredBundles": ["kibanaUtils", "home", "kibanaReact"] diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 3be047859d3b..35a89eb45f35 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -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; @@ -417,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 @@ -439,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; @@ -463,7 +458,14 @@ function discoverController($route, $scope) { $scope.fetchStatus = fetchStatuses.COMPLETE; } - function logInspectorRequest({ searchSessionId = null } = { searchSessionId: null }) { + $scope.refreshAppState = async () => { + $scope.hits = []; + $scope.rows = []; + $scope.fieldCounts = {}; + await refetch$.next(); + }; + + function getRequestResponder({ searchSessionId = null } = { searchSessionId: null }) { inspectorAdapters.requests.reset(); const title = i18n.translate('discover.inspectorRequestDataTitle', { defaultMessage: 'data', @@ -471,11 +473,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 () { diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html index f14800f81d08..fadaffde5c5c 100644 --- a/src/plugins/discover/public/application/angular/discover_legacy.html +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -16,6 +16,7 @@ top-nav-menu="topNavMenu" use-new-fields-api="useNewFieldsApi" unmapped-fields-config="unmappedFieldsConfig" + refresh-app-state="refreshAppState" > diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index e7d5ed469525..9ebeff69d754 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -177,7 +177,7 @@ export function getState({ }, uiSettings ); - // todo filter source depending on fields fetchinbg flag (if no columns remain and source fetching is enabled, use default columns) + // todo filter source depending on fields fetching flag (if no columns remain and source fetching is enabled, use default columns) let previousAppState: AppState; const appStateContainer = createStateContainer(initialAppState); diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts index 050959dff98a..4c6b9002ce86 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts @@ -90,6 +90,7 @@ describe('Row formatter', () => { }, { 'object.value': [5, 10], + getByName: jest.fn(), }, indexPattern ).trim() @@ -107,7 +108,7 @@ describe('Row formatter', () => { }); const formatted = formatTopLevelObject( { fields: { 'a.zzz': [100], 'a.ccc': [50] } }, - { 'a.zzz': [100], 'a.ccc': [50] }, + { 'a.zzz': [100], 'a.ccc': [50], getByName: jest.fn() }, indexPattern ).trim(); expect(formatted.indexOf('
a.ccc:
')).toBeLessThan(formatted.indexOf('
a.zzz:
')); @@ -134,6 +135,7 @@ describe('Row formatter', () => { { 'object.value': [5, 10], 'object.keys': ['a', 'b'], + getByName: jest.fn(), }, indexPattern ).trim() @@ -154,6 +156,7 @@ describe('Row formatter', () => { }, { 'object.value': [5, 10], + getByName: jest.fn(), }, indexPattern ).trim() diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts index a226cefb5396..02902b063479 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts @@ -28,11 +28,13 @@ export const formatRow = (hit: Record, indexPattern: IndexPattern) const highlights = hit?.highlight ?? {}; // Keys are sorted in the hits object const formatted = indexPattern.formatHit(hit); + const fields = indexPattern.fields; const highlightPairs: Array<[string, unknown]> = []; const sourcePairs: Array<[string, unknown]> = []; Object.entries(formatted).forEach(([key, val]) => { + const displayKey = fields.getByName ? fields.getByName(key)?.displayName : undefined; const pairs = highlights[key] ? highlightPairs : sourcePairs; - pairs.push([key, val]); + pairs.push([displayKey ? displayKey : key, val]); }); return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); }; @@ -48,9 +50,11 @@ export const formatTopLevelObject = ( const sorted = Object.entries(fields).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); sorted.forEach(([key, values]) => { const field = indexPattern.getFieldByName(key); + const displayKey = fields.getByName ? fields.getByName(key)?.displayName : undefined; const formatter = field ? indexPattern.getFormatterForField(field) : { convert: (v: string, ...rest: unknown[]) => String(v) }; + if (!values.map) return; const formatted = values .map((val: unknown) => formatter.convert(val, 'html', { @@ -61,7 +65,7 @@ export const formatTopLevelObject = ( ) .join(', '); const pairs = highlights[key] ? highlightPairs : sourcePairs; - pairs.push([key, formatted]); + pairs.push([displayKey ? displayKey : key, formatted]); }); return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); }; diff --git a/src/plugins/discover/public/application/components/create_discover_directive.ts b/src/plugins/discover/public/application/components/create_discover_directive.ts index 5abf87fdfbc0..cc88ef03c5d0 100644 --- a/src/plugins/discover/public/application/components/create_discover_directive.ts +++ b/src/plugins/discover/public/application/components/create_discover_directive.ts @@ -28,5 +28,6 @@ export function createDiscoverDirective(reactDirective: any) { ['updateQuery', { watchDepth: 'reference' }], ['updateSavedQueryId', { watchDepth: 'reference' }], ['unmappedFieldsConfig', { watchDepth: 'value' }], + ['refreshAppState', { watchDepth: 'reference' }], ]); } diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 9615a1c10ea8..6b71bd892b52 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -68,6 +68,7 @@ export function Discover({ searchSource, state, unmappedFieldsConfig, + refreshAppState, }: DiscoverProps) { const [expandedDoc, setExpandedDoc] = useState(undefined); const scrollableDesktop = useRef(null); @@ -203,6 +204,12 @@ export function Discover({ [opts, state] ); + const onEditRuntimeField = () => { + if (refreshAppState) { + refreshAppState(); + } + }; + const columns = useMemo(() => { if (!state.columns) { return []; @@ -245,6 +252,7 @@ export function Discover({ trackUiMetric={trackUiMetric} unmappedFieldsConfig={unmappedFieldsConfig} useNewFieldsApi={useNewFieldsApi} + onEditRuntimeField={onEditRuntimeField} /> diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index dce0a82934c2..03203a79d9dd 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -77,6 +77,9 @@ export const getRenderCellValueFn = ( const sourcePairs: Array<[string, string]> = []; Object.entries(innerColumns).forEach(([key, values]) => { const subField = indexPattern.getFieldByName(key); + const displayKey = indexPattern.fields.getByName + ? indexPattern.fields.getByName(key)?.displayName + : undefined; const formatter = subField ? indexPattern.getFormatterForField(subField) : { convert: (v: string, ...rest: unknown[]) => String(v) }; @@ -90,7 +93,7 @@ export const getRenderCellValueFn = ( ) .join(', '); const pairs = highlights[key] ? highlightPairs : sourcePairs; - pairs.push([key, formatted]); + pairs.push([displayKey ? displayKey : key, formatted]); }); return ( @@ -130,7 +133,10 @@ export const getRenderCellValueFn = ( Object.entries(formatted).forEach(([key, val]) => { const pairs = highlights[key] ? highlightPairs : sourcePairs; - pairs.push([key, val as string]); + const displayKey = indexPattern.fields.getByName + ? indexPattern.fields.getByName(key)?.displayName + : undefined; + pairs.push([displayKey ? displayKey : key, val as string]); }); return ( diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index b0d71c774f44..a630ddda40f3 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -16,6 +16,8 @@ import { EuiToolTip, EuiTitle, EuiIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -69,6 +71,8 @@ export interface DiscoverFieldProps { trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; multiFields?: Array<{ field: IndexPatternField; isSelected: boolean }>; + + onEditField?: (fieldName: string) => void; } export function DiscoverField({ @@ -82,6 +86,7 @@ export function DiscoverField({ selected, trackUiMetric, multiFields, + onEditField, }: DiscoverFieldProps) { const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { defaultMessage: 'Add {field} to table', @@ -250,7 +255,6 @@ export function DiscoverField({ }; const fieldInfoIcon = getFieldInfoIcon(); - const shouldRenderMultiFields = !!multiFields; const renderMultiFields = () => { if (!multiFields) { @@ -282,6 +286,35 @@ export function DiscoverField({ ); }; + const isRuntimeField = Boolean(indexPattern.getFieldByName(field.name)?.runtimeField); + const isUnknownField = field.type === 'unknown' || field.type === 'unknown_selected'; + const canEditField = onEditField && (!isUnknownField || isRuntimeField); + const displayNameGrow = canEditField ? 9 : 10; + const popoverTitle = ( + + + {field.displayName} + {canEditField && ( + + { + if (onEditField) { + togglePopover(); + onEditField(field.name); + } + }} + iconType="pencil" + data-test-subj={`discoverFieldListPanelEdit-${field.name}`} + aria-label={i18n.translate('discover.fieldChooser.discoverField.editFieldLabel', { + defaultMessage: 'Edit index pattern field', + })} + /> + + )} + + + ); + return ( - - {field.displayName} - + {popoverTitle}
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 947972ce1cfc..0b3f55b5630c 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -48,6 +48,12 @@ const mockServices = ({ } }, }, + indexPatternFieldEditor: { + openEditor: jest.fn(), + userPermissions: { + editIndexPattern: jest.fn(), + }, + }, } as unknown) as DiscoverServices; jest.mock('../../../kibana_services', () => ({ @@ -102,6 +108,7 @@ function getCompProps(): DiscoverSidebarProps { fieldFilter: getDefaultFieldFilter(), setFieldFilter: jest.fn(), setAppState: jest.fn(), + onEditRuntimeField: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 1be42e1cd6b1..a3bf2e150d08 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -49,6 +49,17 @@ export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { * Change current state of fieldFilter */ setFieldFilter: (next: FieldFilterState) => void; + + /** + * Callback to close the flyout sidebar rendered in a flyout, close flyout + */ + closeFlyout?: () => void; + + /** + * Pass the reference to field editor component to the parent, so it can be properly unmounted + * @param ref reference to the field editor component + */ + setFieldEditorRef?: (ref: () => void | undefined) => void; } export function DiscoverSidebar({ @@ -72,8 +83,14 @@ export function DiscoverSidebar({ useNewFieldsApi = false, useFlyout = false, unmappedFieldsConfig, + onEditRuntimeField, + setFieldEditorRef, + closeFlyout, }: DiscoverSidebarProps) { const [fields, setFields] = useState(null); + const { indexPatternFieldEditor } = services; + const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); + const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; const [scrollContainer, setScrollContainer] = useState(null); const [fieldsToRender, setFieldsToRender] = useState(FIELDS_PER_PAGE); const [fieldsPerPage, setFieldsPerPage] = useState(FIELDS_PER_PAGE); @@ -220,6 +237,27 @@ export function DiscoverSidebar({ return null; } + const editField = (fieldName: string) => { + if (!canEditIndexPatternField) { + return; + } + const ref = indexPatternFieldEditor.openEditor({ + ctx: { + indexPattern: selectedIndexPattern, + }, + fieldName, + onSave: async () => { + onEditRuntimeField(); + }, + }); + if (setFieldEditorRef) { + setFieldEditorRef(ref); + } + if (closeFlyout) { + closeFlyout(); + } + }; + if (useFlyout) { return (
); @@ -388,6 +427,7 @@ export function DiscoverSidebar({ getDetails={getDetailsByField} trackUiMetric={trackUiMetric} multiFields={multiFields?.get(field.name)} + onEditField={canEditIndexPatternField ? editField : undefined} /> ); @@ -414,6 +454,7 @@ export function DiscoverSidebar({ getDetails={getDetailsByField} trackUiMetric={trackUiMetric} multiFields={multiFields?.get(field.name)} + onEditField={canEditIndexPatternField ? editField : undefined} /> ); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx index 79e8caabd493..caec61cc501b 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx @@ -102,6 +102,7 @@ function getCompProps(): DiscoverSidebarResponsiveProps { setAppState: jest.fn(), state: {}, trackUiMetric: jest.fn(), + onEditRuntimeField: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx index 0808ef47c0dc..6a16399f0e2e 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -121,6 +121,8 @@ export interface DiscoverSidebarResponsiveProps { */ showUnmappedFields: boolean; }; + + onEditRuntimeField: () => void; } /** @@ -132,15 +134,42 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter()); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const closeFieldEditor = useRef<() => void | undefined>(); + + useEffect(() => { + const cleanup = () => { + if (closeFieldEditor?.current) { + closeFieldEditor?.current(); + } + }; + return () => { + // Make sure to close the editor when unmounting + cleanup(); + }; + }, []); + if (!props.selectedIndexPattern) { return null; } + const setFieldEditorRef = (ref: () => void | undefined) => { + closeFieldEditor.current = ref; + }; + + const closeFlyout = () => { + setIsFlyoutVisible(false); + }; + return ( <> {props.isClosed ? null : ( - + )} @@ -215,6 +244,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) fieldFilter={fieldFilter} setFieldFilter={setFieldFilter} alwaysShowActionButtons={true} + setFieldEditorRef={setFieldEditorRef} + closeFlyout={closeFlyout} />
diff --git a/src/plugins/discover/public/application/components/types.ts b/src/plugins/discover/public/application/components/types.ts index 23a3cc9a9bc7..93620bc1d6bc 100644 --- a/src/plugins/discover/public/application/components/types.ts +++ b/src/plugins/discover/public/application/components/types.ts @@ -167,4 +167,6 @@ export interface DiscoverProps { */ showUnmappedFields: boolean; }; + + refreshAppState?: () => void; } diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index e7349ed22355..237da72ae3a5 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/build_services.ts b/src/plugins/discover/public/build_services.ts index 252265692d20..cf95d5a85b9f 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -34,6 +34,7 @@ import { getHistory } from './kibana_services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; +import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -59,6 +60,7 @@ export interface DiscoverServices { getEmbeddableInjector: any; uiSettings: IUiSettingsClient; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; + indexPatternFieldEditor: IndexPatternFieldEditorStart; } export async function buildServices( @@ -100,5 +102,6 @@ export async function buildServices( toastNotifications: core.notifications.toasts, uiSettings: core.uiSettings, trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'), + indexPatternFieldEditor: plugins.indexPatternFieldEditor, }; } diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index 27bcc0023493..e4b0035ed0e0 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 b9719542adc8..3f0907acabdf 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/index_pattern_management/public/components/utils.ts b/src/plugins/index_pattern_management/public/components/utils.ts index 5701a1e37520..68e78199798b 100644 --- a/src/plugins/index_pattern_management/public/components/utils.ts +++ b/src/plugins/index_pattern_management/public/components/utils.ts @@ -14,7 +14,7 @@ export async function getIndexPatterns( indexPatternManagementStart: IndexPatternManagementStart, indexPatternsService: IndexPatternsContract ) { - const existingIndexPatterns = await indexPatternsService.getIdsWithTitle(); + const existingIndexPatterns = await indexPatternsService.getIdsWithTitle(true); const indexPatternsListItems = await Promise.all( existingIndexPatterns.map(async ({ id, title }) => { const isDefault = defaultIndex === id; diff --git a/src/plugins/kibana_utils/common/errors/errors.ts b/src/plugins/kibana_utils/common/errors/errors.ts index 7a9495cc8f41..7f3efc6d9571 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/vis_default_editor/public/_default.scss b/src/plugins/vis_default_editor/public/_default.scss index c412b9d915e5..56c6a0f0f63f 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 94f767510c4b..277804567c2b 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 95843dc6ae3a..f8db2d89888a 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 d5277623a136..eab9665436c0 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 73b5d3f65264..bd1482e14f4f 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 66617c851898..1debfaf951e9 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 d1036aab2dc3..9550697e2285 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 04499d5320ab..6a83dd323b3f 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 0428e6e80ae7..1111a9c52524 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 af9f0750b260..5dacad338e7a 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 74e247b7af06..240b3e68cf65 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 82989cc15d6c..7d42eb3f40ac 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 90353f9af8e3..c380b0e09e7d 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 964017cf886e..7ce432a3bf67 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 77b2e2f02030..45bb5387c5cd 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 4b1528ca2708..09d9f2f1a62f 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 749a97fa79f2..d4caa8a94652 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 5a991238d10f..e7a34c6e6596 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 09cd6d550fd9..562c463f6c83 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 ab5342e925bd..7db6a75e2392 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 0ba8d3e85536..1940ac8b2e9b 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 9003eb7fc2ce..4b13e62430c4 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 5a84598bb5ed..1350e56b68f5 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 32086fbf4f5b..40f1b4f2cc05 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 ceb867e4e6d1..7c0a0f5deb60 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 ebab984ff25a..e3d0cec1a693 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 0cc1188086b7..b50fdb6b8226 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 268c26115233..27e7c5c908b9 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 22a475a9997a..f3ee416be81a 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 e7270371a3fd..46a3c369e548 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 2e759cb6b8b7..1b4434c4867c 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 a9b4f99fdb69..41ed472c3193 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 4639af9db83b..d45943f6f21a 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 345488ec01d5..a93827ba82cd 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 86b691f6496c..29a11bf163e0 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 ce61374c0b12..208321a98737 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 d0e92c9157cb..a5f4e17289e0 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 2772aed82251..b3e88dbf1c6b 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 401344d48f86..dbeb3b1393bd 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 25d62d4f7fe0..01e1b9f8d1dc 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 ad6e84dbc784..972283383716 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 237ed16e5a8b..77b9ccc5880f 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 fdcdfe45d2fd..2a44bf2538a4 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 8f72bd2d1295..9c2bdbe03f88 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 37d188c00eee..984eb385ca4a 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 aff1bd5041be..4840e625383c 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 abb597190877..e15330334639 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 5ce508bd9b27..421f9d2d75f0 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 176721e7b563..3390362b5611 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 76df07b76e80..66783e0cdfae 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 5539f16df41e..9b4b0f244fc2 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 595d49ebbd83..cda022294507 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 b4e07455be0f..b3afc334ac2d 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 ba0271ba286a..a803439c7581 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 9af05afd4118..ae4968e007b1 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 bab3abe13bcb..bc046cbdcf8a 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 1f2735da8fb0..827df30dacf6 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 9b7f6a1c832e..07d2355b2225 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 56f35ae02117..59a7cf966df9 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 6f0b4fc5c9d2..23452a87aae6 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 3081c39530d7..063715b6438e 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 000000000000..81600a4e3601 --- /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 efb166c8975b..3bb52eb15758 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(); } 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 cc0f3ce2afae..9eda709e58c3 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 64d61996495d..965951bfbd88 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 0e529507f97e..c906ff5304c9 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 7404bd1d7f46..cc4859b7016b 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 000000000000..02ab38d4ecb1 --- /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/_data_grid_context.ts b/test/functional/apps/discover/_data_grid_context.ts index 326fba9e6c08..bc259c71b47b 100644 --- a/test/functional/apps/discover/_data_grid_context.ts +++ b/test/functional/apps/discover/_data_grid_context.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await alert?.accept(); expect(await browser.getCurrentUrl()).to.contain('#/context'); await PageObjects.header.waitUntilLoadingHasFinished(); - expect(await docTable.getRowsText()).to.have.length(6); + expect(await docTable.getBodyRows()).to.have.length(6); }); }); } diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index cc62608fbde6..bf90d90cc828 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 72deb74459ab..e41422555f81 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/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts new file mode 100644 index 000000000000..729ad08db81a --- /dev/null +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from './ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const fieldEditor = getService('fieldEditor'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:searchFieldsFromSource': false, + }; + describe('discover integration with runtime fields editor', function describeIndexTests() { + before(async function () { + await esArchiver.load('discover'); + await esArchiver.loadIfNeeded('logstash_functional'); + await kibanaServer.uiSettings.replace(defaultSettings); + log.debug('discover'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + after(async () => { + await kibanaServer.uiSettings.replace({ 'discover:searchFieldsFromSource': true }); + }); + + it('allows adding custom label to existing fields', async function () { + await PageObjects.discover.clickFieldListItemAdd('bytes'); + await PageObjects.discover.editField('bytes'); + await fieldEditor.enableCustomLabel(); + await fieldEditor.setCustomLabel('megabytes'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.discover.getDocHeader()).to.have.string('megabytes'); + expect((await PageObjects.discover.getAllFieldNames()).includes('megabytes')).to.be(true); + }); + }); +} diff --git a/test/functional/apps/discover/index.ts b/test/functional/apps/discover/index.ts index e526cdaccbd4..db76cd1c20c3 100644 --- a/test/functional/apps/discover/index.ts +++ b/test/functional/apps/discover/index.ts @@ -47,6 +47,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_doc_navigation')); loadTestFile(require.resolve('./_data_grid_doc_table')); loadTestFile(require.resolve('./_indexpattern_with_unmapped_fields')); + loadTestFile(require.resolve('./_runtime_fields_editor')); loadTestFile(require.resolve('./_huge_fields')); }); } diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 8db11052d5ed..306d25162939 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -12,7 +12,7 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const es = getService('legacyEs'); - const PageObjects = getPageObjects(['settings', 'common']); + const PageObjects = getPageObjects(['settings', 'common', 'header']); const security = getService('security'); describe('"Create Index Pattern" wizard', function () { @@ -60,6 +60,12 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.createIndexPattern('alias1', false); }); + it('can delete an index pattern', async () => { + await PageObjects.settings.removeIndexPattern(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.exists('indexPatternTable'); + }); + after(async () => { await es.transport.request({ path: '/_aliases', 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 dc02cada9a71..2e3b5d758436 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/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 32288239f984..b4042e7072d7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -255,6 +255,14 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider .map((field) => $(field).text()); } + public async editField(field: string) { + await retry.try(async () => { + await testSubjects.click(`field-${field}`); + await testSubjects.click(`discoverFieldListPanelEdit-${field}`); + await find.byClassName('indexPatternFieldEditor__form'); + }); + } + public async hasNoResults() { return await testSubjects.exists('discoverNoResults'); } diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index 7d6dad4f7858..342e2afec28d 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -16,6 +16,12 @@ export function FieldEditorProvider({ getService }: FtrProviderContext) { public async setName(name: string) { await testSubjects.setValue('nameField > input', name); } + public async enableCustomLabel() { + await testSubjects.setEuiSwitch('customLabelRow > toggle', 'check'); + } + public async setCustomLabel(name: string) { + await testSubjects.setValue('customLabelRow > input', name); + } public async enableValue() { await testSubjects.setEuiSwitch('valueRow > toggle', 'check'); } diff --git a/typings/elasticsearch/search.d.ts b/typings/elasticsearch/search.d.ts index fce08df1c0a0..c9bf3b1d8b7b 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 ed84a00ece49..bfd021ddd816 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/plugins/actions/common/alert_history_schema.test.ts b/x-pack/plugins/actions/common/alert_history_schema.test.ts new file mode 100644 index 000000000000..42a3d98c85fc --- /dev/null +++ b/x-pack/plugins/actions/common/alert_history_schema.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildAlertHistoryDocument } from './alert_history_schema'; + +function getVariables(overrides = {}) { + return { + date: '2021-01-01T00:00:00.000Z', + rule: { + id: 'rule-id', + name: 'rule-name', + type: 'rule-type', + spaceId: 'space-id', + }, + context: { + contextVar1: 'contextValue1', + contextVar2: 'contextValue2', + }, + params: { + ruleParam: 1, + ruleParamString: 'another param', + }, + tags: ['abc', 'def'], + alert: { + id: 'alert-id', + actionGroup: 'action-group-id', + actionGroupName: 'Action Group', + }, + ...overrides, + }; +} + +describe('buildAlertHistoryDocument', () => { + 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 000000000000..e1c923ab23f4 --- /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 184ae9c226b8..336aa2263af0 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 6544a3c426e4..ae7faca1465c 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 c81f1f4a4bf2..1b9de0162f34 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 282ff22f770f..5c0f720e8c5f 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 f7b0e7de478d..3662fea00e31 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 2eecaa19da0c..ad598bffe04b 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 4aa77ded315b..36948478816c 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 ab29f524c202..4d32c2e2bf16 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 30bbedbedbe9..3485891a0126 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 bfe3b0a09ff2..3c754d90c4af 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 000000000000..38556591c4ea --- /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 000000000000..a7038d8dc62e --- /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 000000000000..fe9874fb1d67 --- /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 000000000000..56047f30d948 --- /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 b7a6750a520e..d6f99a766ed3 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 9999ea6a4d3d..2ecf54048569 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 a3a7e9bbd9da..50d710f6d6b1 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 6379192e855d..e325d597da14 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 348bf01ea874..3f9fe9e9c59e 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 e091b53b2e5b..c80541ee1ba6 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/kibana.json b/x-pack/plugins/apm/kibana.json index e340f8bf1912..28e4a7b36e74 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/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 319eb5331323..40d42298b967 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 695a9ba70f5d..88d2e169dd54 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/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts index 473912c4177a..b065da7123de 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 9a0ba514bb47..e3d5e5481caa 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 000000000000..8d250a5765cc --- /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 a9824c130faa..9a362efa90ac 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 d7dd7aee3ca2..5758dea1860b 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 0120891a8f86..8240e0c369d1 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 500e0744d563..6ca1c4370d6a 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 5f6c07cae4b8..b9346b2bf464 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 84c3ec7325fd..15f4a8ea0780 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 148cd813a8a2..be5f4705482d 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 0b2684cdaf08..0865bed41142 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 000000000000..37b3e282d0a5 --- /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/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 074df7eaafd3..cb94b18a1ecf 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -43,6 +43,8 @@ import { 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< @@ -72,15 +74,6 @@ export class APMPlugin core.uiSettings.register(uiSettings); - if (plugins.actions && plugins.alerting) { - registerApmAlerts({ - alerting: plugins.alerting, - actions: plugins.actions, - ml: plugins.ml, - config$: mergedConfig$, - }); - } - const currentConfig = mergeConfigs( plugins.apmOss.config, this.initContext.config.get() @@ -157,6 +150,28 @@ export class APMPlugin 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, @@ -186,6 +201,7 @@ export class APMPlugin }, }); }, + ruleRegistry: apmRuleRegistry, }; } diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 0fec88a4326c..517387c5f74e 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -60,21 +60,3 @@ export interface APMRouteHandlerResources { }; }; } - -// 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 index cef9eaf2f4fc..dbc220f9f6b1 100644 --- a/x-pack/plugins/apm/server/types.ts +++ b/x-pack/plugins/apm/server/types.ts @@ -125,6 +125,7 @@ const requiredDependencies = [ 'triggersActionsUi', 'embeddable', 'infra', + 'observability', ] as const; const optionalDependencies = [ @@ -134,7 +135,6 @@ const optionalDependencies = [ 'taskManager', 'actions', 'alerting', - 'observability', 'security', 'ml', 'home', diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json index ffbf11c23f63..bb341059e2d4 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/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts index 96224644a457..4b27b31ad3e0 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -18,7 +18,6 @@ import { defaultEmbeddableFactoryProvider, EmbeddableContext, PANEL_NOTIFICATION_TRIGGER, - ViewMode, } from '../../../../src/plugins/embeddable/public'; import { EnhancedEmbeddable } from './types'; import { @@ -119,7 +118,6 @@ export class EmbeddableEnhancedPlugin const dynamicActions = new DynamicActionManager({ isCompatible: async (context: unknown) => { if (!this.isEmbeddableContext(context)) return false; - if (context.embeddable.getInput().viewMode !== ViewMode.VIEW) return false; return context.embeddable.runtimeId === embeddable.runtimeId; }, storage, 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 57af8cada989..99f3d340f843 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 ad693628d911..9ad32c6e4863 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 89909c1e51d3..a7eed3b95e6f 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 5f1b165f2c36..3132894ddc7a 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 15c9ee2967d3..3e83717bf935 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 89208a041af3..71d9f39d802d 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 80d60e2de67e..443c130548f6 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 003810ec40a8..9b78d769d2f3 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 73372d7c4aca..cd1042998dd3 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 dcefd0f6bc0b..80cff9b96a3c 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 550093a8cd90..93a684b1968a 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 f1e9343530af..df3e2ec24110 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 52fa81943bb2..e0407b4db7f2 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 cda56fbc4797..545b85c17a52 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 000000000000..d50b35198acb --- /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 000000000000..773fcd19ce9e --- /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 96a135b0db36..638a76511dee 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 efe3186f9780..cd87bc8e6e18 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 5d7be2278d55..dd6ac6350d6e 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 6d3b2208b340..f6ae0a2002dd 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 ab2b9709703c..4c5513a7fc59 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 238cba217da8..a1ac30995f72 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 db88de0ba720..9e23fc775a21 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 350d6439c9d3..3e6ca5944c38 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 adeb56f489ea..56b99f645f97 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 8e9c549fe560..d01d290e129b 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 c85dc06c3828..0959a9a88704 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 dc566b2c435a..3f5f717c9459 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 7059cc96159b..b8a24a006a67 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 846e20b48ddc..aa176fe3b188 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 73ecb0d73b7a..af571d16ca8c 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 24dafa6cca23..0b1db784469a 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 35d2f845ac4c..000000000000 --- 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 5244b8a81e75..8cee4ea58872 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 d775a503d1d3..8fb8ee54d22a 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 f254f1e68ae4..00d01b15750d 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 9086d6436c2a..44b2695ba4e3 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 08e42279e493..f338d7957a34 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 aaeb44bb03aa..0786722e5a47 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 50fec38b9f2d..f818776fdf82 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 4d980834d3a7..3008504f3b06 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 cbadd26ccd4b..c5394a02c1d0 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 ff3cf048b99d..21420095a3ae 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 000000000000..1e1c202b7e60 --- /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 a71e1fb1f1f1..811b0da95245 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 1e10d650bed2..cd7c91685467 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 d51dee31e9f8..ff78296a6043 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 320f99acd08f..1ba883990ce1 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 25d3e5307304..1160038b52af 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 694ae4e07070..0c8a6ee9c9e2 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 bafba412c767..9a45f7f0017c 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 9095ab1927cb..5ab2d68aa193 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/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 4094ecee74e1..9bd482c73bff 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, }, ], @@ -182,7 +183,7 @@ export const datatableVisualization: Visualization { groupId: 'rows', groupLabel: i18n.translate('xpack.lens.datatable.breakdownRows', { - defaultMessage: 'Split rows', + defaultMessage: 'Rows', }), groupTooltip: i18n.translate('xpack.lens.datatable.breakdownRows.description', { defaultMessage: @@ -209,7 +210,7 @@ export const datatableVisualization: Visualization { groupId: 'columns', groupLabel: i18n.translate('xpack.lens.datatable.breakdownColumns', { - defaultMessage: 'Split columns', + defaultMessage: 'Columns', }), groupTooltip: i18n.translate('xpack.lens.datatable.breakdownColumns.description', { defaultMessage: 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 ef8c0798bb91..5538dd26d032 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 8a0b9922c736..f9058b48dd1a 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 cedb648215c0..fcfed9b9f1fc 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/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 62cce21ead63..34e2eb2c9012 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -2089,6 +2089,38 @@ describe('state_helpers', () => { }); }); + it('should remove operations indirectly referencing unavailable fields', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: '', + dataType: 'number', + operationType: 'moving_average', + isBucketed: false, + scale: 'ratio', + references: ['col2'], + timeScale: undefined, + filter: undefined, + params: { + window: 7, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'average', + sourceField: 'xxx', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, newIndexPattern); + expect(updatedLayer.columnOrder).toEqual([]); + expect(updatedLayer.columns).toEqual({}); + }); + it('should remove operations referencing fields with insufficient capabilities', () => { const layer: IndexPatternLayer = { columnOrder: ['col1', 'col2'], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 7853b7da7956..1661e5de8248 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -929,9 +929,17 @@ export function updateLayerIndexPattern( layer: IndexPatternLayer, newIndexPattern: IndexPattern ): IndexPatternLayer { - const keptColumns: IndexPatternLayer['columns'] = _.pickBy(layer.columns, (column) => - isColumnTransferable(column, newIndexPattern) - ); + const keptColumns: IndexPatternLayer['columns'] = _.pickBy(layer.columns, (column) => { + if ('references' in column) { + return ( + isColumnTransferable(column, newIndexPattern) && + column.references.every((columnId) => + isColumnTransferable(layer.columns[columnId], newIndexPattern) + ) + ); + } + return isColumnTransferable(column, newIndexPattern); + }); const newColumns: IndexPatternLayer['columns'] = _.mapValues(keptColumns, (column) => { const operationDefinition = operationDefinitionMap[column.operationType]; return operationDefinition.transfer diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 79155184a5f6..18f653c588ee 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 34b9e4d2b252..e0977be7535a 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 3d34d22c5048..94b4433a8255 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 7910e931e60e..d601e4fa9a39 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 ac3a15d2ac49..82d7b09e9c3f 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 222c49abfa16..2915eaec8ac7 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 c15aa8f414fb..a64a0c0ae09f 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 8dfe9d111ed3..45fcfac7e930 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 6d911ecd5d3c..7be2a3ec8c9e 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 d3e58c4d7bb0..f723c1d72b81 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 df65540445d9..cc7c4cbd15a0 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 8df5b5f6ec66..c6f84351bdc1 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 a1d846c065dc..4c12d05c1f2b 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 1e1f37604957..79986e8ddb09 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/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index f48f4a62f5a7..2d9ae1cd4689 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 000000000000..858ab58b53f4 --- /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 40f97690d779..cbc5a226eb31 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 8fe2c32b766b..000000000000 --- 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 000000000000..9f65449169ee --- /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 7c63d4087ce1..37967d18dbbd 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 000000000000..5c3c6edee59c --- /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 000000000000..79089e7e5baf --- /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 000000000000..7806e531834a --- /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 000000000000..82c699865f2e --- /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 000000000000..8721de497eed --- /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 6979277c4307..45665b2026db 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 9e24a4349584..b410449218d0 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 ea101d104f78..69bdac060a2d 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 f66cd9431460..15e0caa29af3 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 bb90fedfc231..e9527b7c232e 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 b651b311f13a..3e5cf252230a 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 21f07ed9e5a3..28140038d249 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 36e18b49cfa8..ac61e11b1128 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 72de5d003d4b..7aff2ff7e002 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 e07d49ca23d3..caa0e20c3230 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 d26e650d145c..6161eeb4e794 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 b041267f46c0..c258d07cab48 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 f32446fd6d9a..a36d06373770 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 efac51edda69..7045b2eac378 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 b114ca89a328..703851f3fe9b 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 000000000000..e5c6a2345e16 --- /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 000000000000..6b55cb3b6b65 --- /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 000000000000..e50f78a0fd99 --- /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 0287c2af11a7..c6cf608fe1e0 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 000000000000..957321e61b83 --- /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 000000000000..12b12e4ba06d --- /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 000000000000..e39373d66eff --- /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 000000000000..b33e11dae587 --- /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 000000000000..7fd60d0b3428 --- /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 000000000000..c002ddc4ced5 --- /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 5c47d0376581..74efc1f4985a 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 3fcf98f712be..7af325258481 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 c0f3d6dc9b01..7b1d472ac8bb 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 ed849c1eb47b..14cd24c42e6a 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 5b99c19dbabb..67d72a656744 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 139f3ab0d82e..0de78c45041d 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 589a93d16006..12a5b19fb02f 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 8a27d7ddd428..9f8a336b59d3 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 6214975d8f1d..d4b807de11f4 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 6f46c175f788..38d1c425fc09 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 1bc9fed9c3f8..07a521225b38 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 a1a3acd51f89..cd38d912850c 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 7005dea29d60..4b6d5dd6e741 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 4f036f0b9be6..8dad1839f0bc 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 f0ec3f0c31be..efbc3d14441c 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 40c9f5750fb4..68a36dcdcaf8 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 c88567313478..c6b7b5d92d5f 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 0e7bc80e8659..6bc069aafa5b 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 7ead7d5e3cfa..c5a4d0249266 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 76fd64ef8673..de4343b29011 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 274542380c13..555b21618c4b 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 6256b3b134f8..a4fe15025245 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 f291d0de4dac..bac935dbecbe 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 000000000000..029c39df13aa --- /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 039cdfc9b73f..41b9f7d22ba0 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 b6464bbe3c6e..d7e90d34a259 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 000000000000..e05f91b4bb0b --- /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 000000000000..46167af0b244 --- /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 b907efb57d5c..a386b73a8f91 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 567e2654130e..f845bf9885af 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 a473ddb57052..a8f98b98026b 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 053f30152963..2280109fdacd 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 922d33ffd39a..960c2978287b 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 acc9ba9658a0..8fe1d5ed9f2a 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 c6209381a4da..fe54262e1384 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 f83630cff414..000000000000 --- 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 ced04f0a59c8..000000000000 --- 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 fa4202d2c30a..000000000000 --- 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 d673fc4d6f6e..141dcecd0ba5 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 e0a2941b24d3..527ef48364d2 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 cb9878f57888..c59b4dbe373d 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 f55ae640a802..bd37bc09bc13 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/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 85c5379a63b7..01959ed08036 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 000000000000..17fe2b20f74f --- /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/uptime/public/components/monitor/status_details/location_map/index.ts b/x-pack/plugins/rule_registry/common/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/rule_registry/common/index.ts index 650a0a9b8239..6cc0ccaa93a6 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts +++ b/x-pack/plugins/rule_registry/common/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './location_map'; -export * from '../availability_reporting/location_status_tags'; +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 000000000000..d0d15d86a224 --- /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 000000000000..df8ac522e4b5 --- /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 000000000000..dea6ef560cc2 --- /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 000000000000..6e3a8f7cbe66 --- /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 000000000000..cd8865a3f57c --- /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 000000000000..f7cbfc3dfaae --- /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 000000000000..7c4671730081 --- /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 000000000000..9e83d938d508 --- /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 000000000000..9a3d4a38d2ad --- /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 000000000000..95aa180709a5 --- /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 000000000000..db851b7b94c7 --- /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 000000000000..c80f7e772f30 --- /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 000000000000..6e4e13b01d2c --- /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 000000000000..e15b228b0f28 --- /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 000000000000..48ba7c873db2 --- /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 000000000000..f8a88957fceb --- /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 000000000000..0acf80bfb42e --- /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 000000000000..6dc557c016d1 --- /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 000000000000..f1d24550ade0 --- /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 000000000000..9c64e85f839b --- /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 000000000000..f6baf8bcecbd --- /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 000000000000..e6b53a855896 --- /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 000000000000..2961abe6cfec --- /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 3002db642bc1..5df73f7f8ec4 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 5d6b4a1b4fda..f810cd2079d1 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 a59951f5fcfe..813e23a13ff3 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 86f854fd0a14..1efcdf2d792f 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 3ac0084e96fb..c7dd392bf801 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 c5748a321c19..9f3e23fcde1c 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 1f1876756773..ac0bb1f1c742 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 a732f403ee64..07f5fe35bc83 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 c99cabb50e3d..40a202f5257a 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 decd37a7646e..45c1355cecfa 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 6feb5a1501a7..ac60f2999c51 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 000000000000..a1547940765c --- /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 d3bc4c2d50f9..896b0ec5fd8d 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 b2e5638ff120..26b9662a8f19 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 8db9006da615..1b91d396bee3 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 a74022a22252..cc7662cf1e96 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 252260b2c5a2..dbb280228e50 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 f8913148c625..84406aed3619 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 c5cbbeb09ed6..ef7236084508 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 dd636d5a180d..d6693dc1f7a0 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 0a265adf620e..b0b423265180 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 88250fb920d6..93fdf9c5f819 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 1f21a11f22ef..6e62f65f4485 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 09ac156c375e..41b31b04e342 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 e5bea42bc49a..c80d32e09cca 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 b41ba543675e..d87c53ecfba7 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 5d1b090e98a7..a121a682d289 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 b53f90f40f62..64a33068ad68 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 e0326a6c9ff1..cb821061b925 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/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e1e0711c2bb2..3d302aa12832 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/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3f9be18e33d2..fc8ca5204e01 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": "通常", @@ -22898,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": "マイナー", @@ -22928,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": "キャンセル", @@ -23585,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 25aa56d031fc..8aa8b95b01c1 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": "典型", @@ -23257,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": "轻微", @@ -23287,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": "取消", @@ -23954,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/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 00a029a9abb5..975765304317 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 bc09e5abe112..f4b8284c8cfa 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,6 +73,7 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} + actionConnector={actionConnector} messageVariables={[ { name: 'myVar', @@ -35,5 +87,76 @@ describe('IndexParamsFields renders', () => { "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 c65c76ee6916..6973cdcc7a08 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 f3dbfc9c363c..cae4221e5d7c 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 4d47cbf3685a..f67267a75ed3 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 @@ -57,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 6923c8dac000..98dd9c6bf843 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 8a1b2bfb4ac2..d94cdde349dc 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/lib/action_variables.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts index 435e4c5637ee..1414242358d5 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 29f2b277c97a..9722cc42ed39 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 7fbe68776ca2..48c6c1b42d7a 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 cf2dda203bb2..1fd031cda6d9 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 426d3f1f10db..4ba836c1e5d2 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 73ac77a61461..2c95ac334772 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 94cbeb49a32c..000000000000 --- 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 2edb2eec4658..855b8ef0c976 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 5f74098e1258..c851369d63e9 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 45cb5c45bf02..000000000000 --- 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 fa77d0bf9057..000000000000 --- 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 6b3d157c23fe..000000000000 --- 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 5e3e2e1a6db4..000000000000 --- 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 b925697970a5..000000000000 --- 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 6706a435c7b6..000000000000 --- 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 7a309cd01ebc..000000000000 --- 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 5ad92d4e6d1d..000000000000 --- 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 723eee6f14b8..000000000000 --- 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 c03ed94f8c54..000000000000 --- 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 9818fc164193..000000000000 --- 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 5a912a44b7c9..000000000000 --- 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 dad7a61e7499..000000000000 --- 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 7b03f516deca..000000000000 --- 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 17b3354b666c..592fd5698470 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 90306466a975..b7df493a1036 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 1761c4481343..deb91f6b9b1e 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 032186b2e90e..a2f0e835c0b3 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 beb639eb4633..6a0ab5408784 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 c397a2659557..49d5f52869b8 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 000000000000..cf8a0f99d439 --- /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 531df9d4ed19..08241f2ad8e2 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 d5056508e5de..43f442c13162 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/config.ts b/x-pack/test/apm_api_integration/common/config.ts index 04ce83323ee6..732f14d2a7bc 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 4eeb57e3c86c..91437a2d22e2 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/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/translations.ts b/x-pack/test/apm_api_integration/rules/config.ts similarity index 54% rename from x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/translations.ts rename to x-pack/test/apm_api_integration/rules/config.ts index edbf2b5b5e86..9830d516eb80 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/translations.ts +++ b/x-pack/test/apm_api_integration/rules/config.ts @@ -5,11 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { configs } from '../configs'; -export const MAP_TITLE = i18n.translate( - 'xpack.uptime.components.embeddables.embeddedMap.embeddablePanelTitle', - { - defaultMessage: 'Monitor Observer Location Map', - } -); +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 000000000000..97026d126d2a --- /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 7c69d5b996ce..53ec61b8d9b6 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/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index d49bc91251b0..91d6ca0119d1 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_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_agents_setup', () => { + describe('fleet_agents_setup', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); 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 2569d9aef4b5..d9946bb174f5 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 0a7002764a54..5a991e52bdba 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 a82ed3f8cf22..c9709475d182 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 161976594691..f7bfd7f7a4c6 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 d0c7c86d6d5c..891805acb325 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 5dd1890e240a..91a349e1bf44 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 28e6bc93c408..3e7a4817eeef 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 8ee028ae3f56..14e08992de9b 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 786143fb3d1e..0e6427d2e265 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10950,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"