diff --git a/NOTICE.txt b/NOTICE.txt index 2341a478cbda9..4eec329b7a603 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -261,33 +261,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- -This product bundles childnode-remove which is available under a -"MIT" license. - -The MIT License (MIT) - -Copyright (c) 2016-present, jszhou -https://github.com/jserz/js_piece - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --- This product bundles code based on probot-metadata@1.0.0 which is available under a "MIT" license. diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index c1e727b1eac65..3827cb6e9aa7d 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -14,10 +14,14 @@ Theses files must be updated when upgrading Node.js: - {kib-repo}blob/{branch}/.node-version[`.node-version`] - {kib-repo}blob/{branch}/.nvmrc[`.nvmrc`] - {kib-repo}blob/{branch}/package.json[`package.json`] - The version is specified in the `engines.node` field. + - {kib-repo}blob/{branch}/WORKSPACE.bazel[`WORKSPACE.bazel`] - The version is specified in the `node_version` property. + Besides this property, the list of files under `node_repositories` must be updated along with their respective SHA256 hashes. + These can be found on the https://nodejs.org[nodejs.org] website. + Example for Node.js v14.16.1: https://nodejs.org/dist/v14.16.1/SHASUMS256.txt.asc -See PR {kib-repo}pull/86593[#86593] for an example of how the Node.js version has been upgraded previously. +See PR {kib-repo}pull/96382[#96382] for an example of how the Node.js version has been upgraded previously. -In the 6.8 branch, the `.ci/Dockerfile` file does not exist, so when upgrading Node.js in that branch, just skip that file. +In the 6.8 branch, neither the `.ci/Dockerfile` file nor the `WORKSPACE.bazel` file exists, so when upgrading Node.js in that branch, just skip those files. === Backporting diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md index 4d75dda61d5c9..521ceeb1e37f2 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md @@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES | HISTOGRAM | "histogram" | | | IP | "ip" | | | IP\_RANGE | "ip_range" | | +| MISSING | "missing" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | | NUMBER | "number" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md index be4c3705bd8de..40fa872ff0fc6 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md @@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES | HISTOGRAM | "histogram" | | | IP | "ip" | | | IP\_RANGE | "ip_range" | | +| MISSING | "missing" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | | NUMBER | "number" | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md index 5201444e69867..290dc10662569 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md @@ -9,9 +9,9 @@ Merges input$ and output$ streams and debounces emit till next macro-task. Could Signature: ```typescript -getUpdated$(): Readonly>; +getUpdated$(): Readonly>; ``` Returns: -`Readonly>` +`Readonly>` diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index 1bdc9b9dea859..5e6a60f019bea 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -12,7 +12,7 @@ When you've finished, you'll know how to: [float] === Required privileges When security is enabled, you must have `read`, `write`, and `manage` privileges on the `kibana_sample_data_*` indices. -For more information, refer to {ref}/security-privileges.html[Security privileges]. +Learn how to <>, or refer to {ref}/security-privileges.html[Security privileges] for more information. [float] [[set-up-on-cloud]] @@ -141,3 +141,5 @@ For more information, refer to <>. If you are you ready to add your own data, refer to <>. If you want to ingest your data, refer to {fleet-guide}/fleet-quick-start.html[Quick start: Get logs and metrics into the Elastic Stack]. + +If you want to secure access to your data, refer to our guide on <> diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index e4d2b53a2d8d6..5d0242ae31950 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -286,3 +286,9 @@ This content has moved. See {ref}/ingest.html[Ingest pipelines]. == Timelion This content has moved. refer to <>. + + +[role="exclude",id="space-rbac-tutorial"] +== Tutorial: Use role-based access control to customize Kibana spaces + +This content has moved. refer to <>. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 643718b961650..90e813afad6f4 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -429,6 +429,15 @@ to display map tiles in tilemap visualizations. By default, override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` +| `migrations.batchSize:` + | Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If the migration fails due to a `circuit_breaking_exception`, set a smaller `batchSize` value. *Default: `1000`* + +| `migrations.enableV2:` + | experimental[]. Enables the new Saved Objects migration algorithm. For information about the migration algorithm, refer to <>. When `migrations v2` is stable, the setting will be removed in an upcoming release without any further notice. Setting the value to `false` causes {kib} to use the legacy migration algorithm, which shipped in 7.11 and earlier versions. *Default: `true`* + +| `migrations.retryAttempts:` + | The number of times migrations retry temporary failures, such as a network timeout, 503 status code, or `snapshot_in_progress_exception`. When upgrade migrations frequently fail after exhausting all retry attempts with a message such as `Unable to complete the [...] step after 15 attempts, terminating.`, increase the setting value. *Default: `15`* + | `newsfeed.enabled:` | Controls whether to enable the newsfeed system for the {kib} UI notification center. Set to `false` to disable the diff --git a/docs/user/security/images/role-index-privilege.png b/docs/user/security/images/role-index-privilege.png deleted file mode 100644 index 1dc1ae640e3ba..0000000000000 Binary files a/docs/user/security/images/role-index-privilege.png and /dev/null differ diff --git a/docs/user/security/images/role-management.png b/docs/user/security/images/role-management.png deleted file mode 100644 index 29efdd85c4df3..0000000000000 Binary files a/docs/user/security/images/role-management.png and /dev/null differ diff --git a/docs/user/security/images/role-new-user.png b/docs/user/security/images/role-new-user.png deleted file mode 100644 index c882eeea42d60..0000000000000 Binary files a/docs/user/security/images/role-new-user.png and /dev/null differ diff --git a/docs/user/security/images/role-space-visualization.png b/docs/user/security/images/role-space-visualization.png deleted file mode 100644 index 36f83f09f064b..0000000000000 Binary files a/docs/user/security/images/role-space-visualization.png and /dev/null differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-role.png b/docs/user/security/images/tutorial-secure-access-example-1-role.png new file mode 100644 index 0000000000000..53540da7170ea Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-role.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-space.png b/docs/user/security/images/tutorial-secure-access-example-1-space.png new file mode 100644 index 0000000000000..a48fdeaa6efa1 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-space.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-test.png b/docs/user/security/images/tutorial-secure-access-example-1-test.png new file mode 100644 index 0000000000000..305b97017a9d8 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-test.png differ diff --git a/docs/user/security/images/tutorial-secure-access-example-1-user.png b/docs/user/security/images/tutorial-secure-access-example-1-user.png new file mode 100644 index 0000000000000..8df26cf28ef16 Binary files /dev/null and b/docs/user/security/images/tutorial-secure-access-example-1-user.png differ diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index 6a5c4a83aa3ad..71c5bd268a67d 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -47,4 +47,3 @@ include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] include::encryption-keys/index.asciidoc[] include::role-mappings/index.asciidoc[] -include::rbac_tutorial.asciidoc[] diff --git a/docs/user/security/rbac_tutorial.asciidoc b/docs/user/security/rbac_tutorial.asciidoc deleted file mode 100644 index 6324539c3c10a..0000000000000 --- a/docs/user/security/rbac_tutorial.asciidoc +++ /dev/null @@ -1,105 +0,0 @@ -[[space-rbac-tutorial]] -=== Tutorial: Use role-based access control to customize Kibana spaces - -With role-based access control (RBAC), you can provide users access to data, tools, -and Kibana spaces. In this tutorial, you will learn how to configure roles -that provide the right users with the right access to the data, tools, and -Kibana spaces. - -[float] -==== Scenario - -Our user is a web developer working on a bank's -online mortgage service. The web developer has these -three requirements: - -* Have access to the data for that service -* Build visualizations and dashboards -* Monitor the performance of the system - -You'll provide the web developer with the access and privileges to get the job done. - -[float] -==== Prerequisites - -To complete this tutorial, you'll need the following: - -* **Administrative privileges**: You must have a role that grants privileges to create a space, role, and user. This is any role which grants the `manage_security` cluster privilege. By default, the `superuser` role provides this access. See the {ref}/built-in-roles.html[built-in] roles. -* **A space**: In this tutorial, use `Dev Mortgage` as the space -name. See <> for -details on creating a space. -* **Data**: You can use <> or -live data. In the following steps, Filebeat and Metricbeat data are used. - -[float] -==== Steps - -With the requirements in mind, here are the steps that you will work -through in this tutorial: - -* Create a role named `mortgage-developer` -* Give the role permission to access the data in the relevant indices -* Give the role permission to create visualizations and dashboards -* Create the web developer's user account with the proper roles - -[float] -==== Create a role - -Open the main menu, then click *Stack Management > Roles* -for an overview of your roles. This view provides actions -for you to create, edit, and delete roles. - -[role="screenshot"] -image::security/images/role-management.png["Role management"] - - -You can create as many roles as you like. Click *Create role* and -provide a name. Use `dev-mortgage` because this role is for a developer -working on the bank's mortgage application. - - -[float] -==== Give the role permission to access the data - -Access to data in indices is an index-level privilege, so in -*Index privileges*, add lines for the indices that contain the -data for this role. Two privileges are required: `read` and -`view_index_metadata`. All privileges are detailed in the -https://www.elastic.co/guide/en/elasticsearch/reference/current/security-privileges.html[security privileges] documentation. - -In the screenshots, Filebeat and Metricbeat data is used, but you -should use the index patterns for your indices. - -[role="screenshot"] -image::security/images/role-index-privilege.png["Index privilege"] - -[float] -==== Give the role permissions to {kib} apps - -To enable users to create dashboards, visualizations, and saved searches, add {kib} privileges to the `dev-mortgage` role. - -. On the *{kib} privileges* window, select *Dev Mortgage* from the *Space* dropdown. - -. Click **Add space privilege**. - -. For *Dashboard*, *Visualize Library*, and *Discover*, click *All*. -+ -It is common to create saved searches in *Discover* while creating visualizations. -+ -[role="screenshot"] -image::security/images/role-space-visualization.png["Associate space"] - -[float] -==== Create the developer user account with the proper roles - -. Open the main menu, then click *Stack Management > Users*. -. Click **Create user**, then give the user the `dev-mortgage` -and `monitoring-user` roles, which are required for *Stack Monitoring* users. - -[role="screenshot"] -image::security/images/role-new-user.png["Developer user"] - -Finally, have the developer log in and access the Dev Mortgage space -and create a new visualization. - -NOTE: If the user is assigned to only one space, they will automatically enter that space on login. diff --git a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc new file mode 100644 index 0000000000000..63b83712e3e6e --- /dev/null +++ b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc @@ -0,0 +1,136 @@ +[[tutorial-secure-access-to-kibana]] +== Securing access to {kib} + + +{kib} is home to an ever-growing suite of powerful features, which help you get the most out of your data. Your data is important, and should be protected. {kib} allows you to secure access to your data and control how users are able to interact with your data. + +For example, some users might only need to view your stunning dashboards, while others might need to manage your fleet of Elastic agents and run machine learning jobs to detect anomalous behavior in your network. + +This guide introduces you to three of {kib}'s security features: spaces, roles, and users. By the end of this tutorial, you will learn how to manage these entities, and how you can leverage them to secure access to both {kib} and your data. + +[float] +=== Spaces + +Do you have multiple teams using {kib}? Do you want a “playground” to experiment with new visualizations or alerts? If so, then <> can help. + +Think of a space as another instance of {kib}. A space allows you to organize your <>, <>, <>, and much more into their own categories. For example, you might have a Marketing space for your marketeers to track the results of their campaigns, and an Engineering space for your developers to {apm-get-started-ref}/overview.html[monitor application performance]. + +The assets you create in one space are isolated from other spaces, so when you enter a space, you only see the assets that belong to that space. + +Refer to the <> for more information. + +[float] +=== Roles + +Once your spaces are setup, the next step to securing access is to provision your roles. Roles are a collection of privileges that allow you to perform actions in {kib} and Elasticsearch. Roles are assigned to users, and to {ref}/built-in-users.html[system accounts] that power the Elastic Stack. + +You can create your own roles, or use any of the {ref}/built-in-roles.html[built-in roles]. Some built-in roles are intended for Elastic Stack components and should not be assigned to end users directly. + +One of the more useful built-in roles is `kibana_admin`. Assigning this role to your users will grant access to all of {kib}'s features. This includes the ability to manage Spaces. + +The built-in roles are great for getting started with the Elastic Stack, and for system administrators who do not need more restrictive access. With so many features, it’s not possible to ship more granular roles to accommodate everyone’s needs. This is where custom roles come in. + +As an administrator, you have the ability to create your own roles to describe exactly the kind of access your users should have. For example, you might create a `marketing_user` role, which you then assign to all users in your marketing department. This role would grant access to all of the necessary data and features for this team to be successful, without granting them access they don’t require. + + +[float] +=== Users + +Once your roles are setup, the next step to securing access is to create your users, and assign them one or more roles. {kib}'s user management allows you to provision accounts for each of your users. + +TIP: Want Single Sign-on? {kib} supports a wide range of SSO implementations, including SAML, OIDC, LDAP/AD, and Kerberos. <>. + + +[float] +[[tutorial-secure-kibana-dashboards-only]] +=== Example: Create a user with access only to dashboards + +Let’s work through an example together. Consider a marketing analyst who wants to monitor the effectiveness of their campaigns. They should be able to see their team’s dashboards, but not be allowed to view or manage anything else in {kib}. All of the team’s dashboards are located in the Marketing space. + +[float] +==== Create a space + +Create a Marketing space for your marketing analysts to use. + +. Open the main menu, and select **Stack Management**. +. Under **{kib}**, select **Spaces**. +. Click **Create a space**. +. Give this space a unique name. For example: `Marketing`. +. Click **Create space**. ++ +If you’ve followed the example above, you should end up with a space that looks like this: ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-space.png[Create space UI] + + +[float] +==== Create a role + +To effectively use dashboards, create a role that describes the privileges you want to grant. +In this example, a marketing analyst will need: + +* Access to **read** the data that powers the dashboards +* Access to **read** the dashboards within the `Marketing` space + +To create the role: + +. Open the main menu, and select **Stack Management**. +. Under **Security**, select **Roles**. +. Click **Create role**. +. Give this role a unique name. For example: `marketing_dashboards_role`. +. For this example, you want to store all marketing data in the `acme-marketing-*` set of indices. To grant this access, locate the **Index privileges** section and enter: +.. `acme-marketing-*` in the **Indices** field. +.. `read` and `view_index_metadata` in the **Privileges** field. ++ +TIP: You can add multiple patterns of indices, and grant different access levels to each. Click **Add index privilege** to grant additional access. +. To grant access to dashboards in the `Marketing` space, locate the {kib} section, and click **Add {kib} privilege**: +.. From the **Spaces** dropdown, select the `Marketing` space. +.. Expand the **Analytics** section, and select the **Read** privilege for **Dashboard**. +.. Click **Add Kibana privilege**. +. Click **Create role**. ++ +If you’ve followed the example above, you should end up with a role that looks like this: ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-role.png[Create role UI] + + +[float] +==== Create a user + +Now that you created a role, create a user account. + +. Navigate to *Stack Management*, and under *Security*, select *Users*. +. Click *Create user*. +. Give this user a descriptive username, and choose a secure password. +. Assign the *marketing_dashboards_role* that you previously created to this new user. +. Click *Create user*. + +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-user.png[Create user UI] + +[float] +==== Verify + +Verify that the user and role are working correctly. + +. Logout of {kib} if you are already logged in. +. In the login screen, enter the username and password for the account you created. ++ +You’re taken into the `Marketing` space, and the main navigation shows only the *Dashboard* application. ++ +[role="screenshot"] +image::user/security/images/tutorial-secure-access-example-1-test.png[Verifying access to dashboards] + + +[float] +=== What's next? + +This guide is an introduction to {kib}'s security features. Check out these additional resources to learn more about authenticating and authorizing your users. + +* View the <> to learn more about single-sign on and other login features. + +* View the <> to learn more about authorizing access to {kib}'s features. + +Still have questions? Ask on our https://discuss.elastic.co/c/kibana[Kibana discuss forum] and a fellow community member or Elastic engineer will help out. diff --git a/docs/user/setup.asciidoc b/docs/user/setup.asciidoc index a38bf699c1db8..bea13c1ef49b2 100644 --- a/docs/user/setup.asciidoc +++ b/docs/user/setup.asciidoc @@ -54,6 +54,8 @@ include::{kib-repo-dir}/setup/start-stop.asciidoc[] include::{kib-repo-dir}/setup/access.asciidoc[] +include::security/tutorials/how-to-secure-access-to-kibana.asciidoc[] + include::{kib-repo-dir}/setup/connect-to-elasticsearch.asciidoc[] include::{kib-repo-dir}/setup/upgrade.asciidoc[] diff --git a/package.json b/package.json index a1acf73ea26f0..9bddca4665467 100644 --- a/package.json +++ b/package.json @@ -131,10 +131,12 @@ "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", + "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", "@kbn/std": "link:packages/kbn-std", "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", @@ -206,7 +208,6 @@ "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", "core-js": "^3.6.5", - "custom-event-polyfill": "^0.3.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", diff --git a/packages/kbn-io-ts-utils/jest.config.js b/packages/kbn-io-ts-utils/jest.config.js new file mode 100644 index 0000000000000..1a71166fae843 --- /dev/null +++ b/packages/kbn-io-ts-utils/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-io-ts-utils'], +}; diff --git a/packages/kbn-io-ts-utils/package.json b/packages/kbn-io-ts-utils/package.json new file mode 100644 index 0000000000000..4d6f02d3f85a6 --- /dev/null +++ b/packages/kbn-io-ts-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/io-ts-utils", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + } +} diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts new file mode 100644 index 0000000000000..2032127b1eb91 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { jsonRt } from './json_rt'; +export { mergeRt } from './merge_rt'; +export { strictKeysRt } from './strict_keys_rt'; diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts similarity index 85% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.test.ts index d6c286c672d90..1220639fc7bef 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -12,9 +13,7 @@ import { Right } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; -function getValueOrThrow>( - either: TEither -): Right { +function getValueOrThrow>(either: TEither): Right { const value = pipe( either, fold(() => { diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts b/packages/kbn-io-ts-utils/src/json_rt/index.ts similarity index 74% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.ts index 0207145a17be7..bc596d53db54c 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/merge/index.test.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.test.ts index af5a0221662d5..b25d4451895f2 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts @@ -1,18 +1,19 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { isLeft } from 'fp-ts/lib/Either'; -import { merge } from './'; +import { mergeRt } from '.'; import { jsonRt } from '../json_rt'; describe('merge', () => { it('fails on one or more errors', () => { - const type = merge([t.type({ foo: t.string }), t.type({ bar: t.number })]); + const type = mergeRt(t.type({ foo: t.string }), t.type({ bar: t.number })); const result = type.decode({ foo: '' }); @@ -20,10 +21,7 @@ describe('merge', () => { }); it('merges left to right', () => { - const typeBoolean = merge([ - t.type({ foo: t.string }), - t.type({ foo: jsonRt.pipe(t.boolean) }), - ]); + const typeBoolean = mergeRt(t.type({ foo: t.string }), t.type({ foo: jsonRt.pipe(t.boolean) })); const resultBoolean = typeBoolean.decode({ foo: 'true', @@ -34,10 +32,7 @@ describe('merge', () => { foo: true, }); - const typeString = merge([ - t.type({ foo: jsonRt.pipe(t.boolean) }), - t.type({ foo: t.string }), - ]); + const typeString = mergeRt(t.type({ foo: jsonRt.pipe(t.boolean) }), t.type({ foo: t.string })); const resultString = typeString.decode({ foo: 'true', @@ -50,10 +45,10 @@ describe('merge', () => { }); it('deeply merges values', () => { - const type = merge([ + const type = mergeRt( t.type({ foo: t.type({ baz: t.string }) }), - t.type({ foo: t.type({ bar: t.string }) }), - ]); + t.type({ foo: t.type({ bar: t.string }) }) + ); const result = type.decode({ foo: { diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.ts similarity index 62% rename from x-pack/plugins/apm/common/runtime_types/merge/index.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.ts index 451edf678aabe..c582767fb5101 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.ts @@ -1,31 +1,40 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { merge as lodashMerge } from 'lodash'; import { isLeft } from 'fp-ts/lib/Either'; -import { ValuesType } from 'utility-types'; -export type MergeType< - T extends t.Any[], - U extends ValuesType = ValuesType -> = t.Type & { - _tag: 'MergeType'; - types: T; -}; +type PlainObject = Record; + +type DeepMerge = U extends PlainObject + ? T extends PlainObject + ? Omit & + { + [key in keyof U]: T extends { [k in key]: any } ? DeepMerge : U[key]; + } + : U + : U; // this is similar to t.intersection, but does a deep merge // instead of a shallow merge -export function merge( - types: [A, B] -): MergeType<[A, B]>; +export type MergeType = t.Type< + DeepMerge, t.TypeOf>, + DeepMerge, t.OutputOf> +> & { + _tag: 'MergeType'; + types: [T1, T2]; +}; + +export function mergeRt(a: T1, b: T2): MergeType; -export function merge(types: t.Any[]) { +export function mergeRt(...types: t.Any[]) { const mergeType = new t.Type( 'merge', (u): u is unknown => { diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts similarity index 77% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts index 4212e0430ff5f..ab20ca42a283e 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -14,10 +15,7 @@ describe('strictKeysRt', () => { it('correctly and deeply validates object keys', () => { const checks: Array<{ type: t.Type; passes: any[]; fails: any[] }> = [ { - type: t.intersection([ - t.type({ foo: t.string }), - t.partial({ bar: t.string }), - ]), + type: t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.string })]), passes: [{ foo: '' }, { foo: '', bar: '' }], fails: [ { foo: '', unknownKey: '' }, @@ -26,15 +24,9 @@ describe('strictKeysRt', () => { }, { type: t.type({ - path: t.union([ - t.type({ serviceName: t.string }), - t.type({ transactionType: t.string }), - ]), + path: t.union([t.type({ serviceName: t.string }), t.type({ transactionType: t.string })]), }), - passes: [ - { path: { serviceName: '' } }, - { path: { transactionType: '' } }, - ], + passes: [{ path: { serviceName: '' } }, { path: { transactionType: '' } }], fails: [ { path: { serviceName: '', unknownKey: '' } }, { path: { transactionType: '', unknownKey: '' } }, @@ -62,9 +54,7 @@ describe('strictKeysRt', () => { if (!isRight(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be allowed, but validation failed with ${ + `Expected ${JSON.stringify(value)} to be allowed, but validation failed with ${ result.left[0].message }` ); @@ -76,9 +66,7 @@ describe('strictKeysRt', () => { if (!isLeft(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be disallowed, but validation succeeded` + `Expected ${JSON.stringify(value)} to be disallowed, but validation succeeded` ); } }); diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts index e90ccf7eb8d31..56afdf54463f7 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts @@ -1,14 +1,15 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { either, isRight } from 'fp-ts/lib/Either'; import { mapValues, difference, isPlainObject, forEach } from 'lodash'; -import { MergeType, merge } from '../merge'; +import { MergeType, mergeRt } from '../merge_rt'; /* Type that tracks validated keys, and fails when the input value @@ -21,7 +22,7 @@ type ParsableType = | t.PartialType | t.ExactType | t.InterfaceType - | MergeType; + | MergeType; function getKeysInObject>( object: T, @@ -32,17 +33,16 @@ function getKeysInObject>( const ownPrefix = prefix ? `${prefix}.${key}` : key; keys.push(ownPrefix); if (isPlainObject(object[key])) { - keys.push( - ...getKeysInObject(object[key] as Record, ownPrefix) - ); + keys.push(...getKeysInObject(object[key] as Record, ownPrefix)); } }); return keys; } -function addToContextWhenValidated< - T extends t.InterfaceType | t.PartialType ->(type: T, prefix: string): T { +function addToContextWhenValidated | t.PartialType>( + type: T, + prefix: string +): T { const validate = (input: unknown, context: t.Context) => { const result = type.validate(input, context); const keysType = context[0].type as StrictKeysType; @@ -50,36 +50,19 @@ function addToContextWhenValidated< throw new Error('Expected a top-level StrictKeysType'); } if (isRight(result)) { - keysType.trackedKeys.push( - ...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`) - ); + keysType.trackedKeys.push(...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`)); } return result; }; if (type._tag === 'InterfaceType') { - return new t.InterfaceType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.InterfaceType(type.name, type.is, validate, type.encode, type.props) as T; } - return new t.PartialType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.PartialType(type.name, type.is, validate, type.encode, type.props) as T; } -function trackKeysOfValidatedTypes( - type: ParsableType | t.Any, - prefix: string = '' -): t.Any { +function trackKeysOfValidatedTypes(type: ParsableType | t.Any, prefix: string = ''): t.Any { if (!('_tag' in type)) { return type; } @@ -89,27 +72,24 @@ function trackKeysOfValidatedTypes( case 'IntersectionType': { const collectionType = type as t.IntersectionType; return t.intersection( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'UnionType': { const collectionType = type as t.UnionType; return t.union( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'MergeType': { - const collectionType = type as MergeType; - return merge( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + const collectionType = type as MergeType; + return mergeRt( + ...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [ + t.Any, + t.Any + ]) ); } @@ -142,9 +122,7 @@ function trackKeysOfValidatedTypes( case 'ExactType': { const exactType = type as t.ExactType; - return t.exact( - trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps - ); + return t.exact(trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps); } default: @@ -169,17 +147,11 @@ class StrictKeysType< (input, context) => { this.trackedKeys.length = 0; return either.chain(trackedType.validate(input, context), (i) => { - const originalKeys = getKeysInObject( - input as Record - ); + const originalKeys = getKeysInObject(input as Record); const excessKeys = difference(originalKeys, this.trackedKeys); if (excessKeys.length) { - return t.failure( - i, - context, - `Excess keys are not allowed: \n${excessKeys.join('\n')}` - ); + return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`); } return t.success(i); diff --git a/packages/kbn-io-ts-utils/tsconfig.json b/packages/kbn-io-ts-utils/tsconfig.json new file mode 100644 index 0000000000000..6c67518e21073 --- /dev/null +++ b/packages/kbn-io-ts-utils/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-io-ts-utils/src", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-server-route-repository/README.md b/packages/kbn-server-route-repository/README.md new file mode 100644 index 0000000000000..e22205540ef31 --- /dev/null +++ b/packages/kbn-server-route-repository/README.md @@ -0,0 +1,7 @@ +# @kbn/server-route-repository + +Utility functions for creating a typed server route repository, and a typed client, generating runtime validation and type validation from the same route definition. + +## Usage + +TBD diff --git a/packages/kbn-server-route-repository/jest.config.js b/packages/kbn-server-route-repository/jest.config.js new file mode 100644 index 0000000000000..7449bb7cd3860 --- /dev/null +++ b/packages/kbn-server-route-repository/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-server-route-repository'], +}; diff --git a/packages/kbn-server-route-repository/package.json b/packages/kbn-server-route-repository/package.json new file mode 100644 index 0000000000000..ce1ca02d0c4f6 --- /dev/null +++ b/packages/kbn-server-route-repository/package.json @@ -0,0 +1,16 @@ +{ + "name": "@kbn/server-route-repository", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": { + "@kbn/io-ts-utils": "link:../kbn-io-ts-utils" + } +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_factory.ts b/packages/kbn-server-route-repository/src/create_server_route_factory.ts new file mode 100644 index 0000000000000..edf9bd657f995 --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_factory.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { + ServerRouteCreateOptions, + ServerRouteHandlerResources, + RouteParamsRT, + ServerRoute, +} from './typings'; + +export function createServerRouteFactory< + TRouteHandlerResources extends ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions +>(): < + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined +>( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > +) => ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions +> { + return (route) => route; +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_repository.ts b/packages/kbn-server-route-repository/src/create_server_route_repository.ts new file mode 100644 index 0000000000000..5ac89ebcac77f --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_repository.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { + ServerRouteHandlerResources, + ServerRouteRepository, + ServerRouteCreateOptions, +} from './typings'; + +export function createServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = never, + TRouteCreateOptions extends ServerRouteCreateOptions = never +>(): ServerRouteRepository { + let routes: Record = {}; + + return { + add(route) { + routes = { + ...routes, + [route.endpoint]: route, + }; + + return this as any; + }, + merge(repository) { + routes = { + ...routes, + ...Object.fromEntries(repository.getRoutes().map((route) => [route.endpoint, route])), + }; + + return this as any; + }, + getRoutes: () => Object.values(routes), + }; +} diff --git a/packages/kbn-server-route-repository/src/decode_request_params.test.ts b/packages/kbn-server-route-repository/src/decode_request_params.test.ts new file mode 100644 index 0000000000000..08ef303ad0b3a --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { jsonRt } from '@kbn/io-ts-utils'; +import * as t from 'io-ts'; +import { decodeRequestParams } from './decode_request_params'; + +describe('decodeRequestParams', () => { + it('decodes request params', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + path: { + serviceName: 'opbeans-java', + }, + query: { + start: '', + }, + }); + }); + + it('fails on excess keys', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + extraKey: '', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + + expect(decode).toThrowErrorMatchingInlineSnapshot(` + "Excess keys are not allowed: + path.extraKey" + `); + }); + + it('returns the decoded output', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: { + _inspect: 'true', + }, + body: null, + }, + t.type({ + query: t.type({ + _inspect: jsonRt.pipe(t.boolean), + }), + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + query: { + _inspect: true, + }, + }); + }); + + it('strips empty params', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: {}, + body: {}, + }, + t.type({ + body: t.any, + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({}); + }); +}); diff --git a/packages/kbn-server-route-repository/src/decode_request_params.ts b/packages/kbn-server-route-repository/src/decode_request_params.ts new file mode 100644 index 0000000000000..00492d69b8ac5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { omitBy, isPlainObject, isEmpty } from 'lodash'; +import { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import Boom from '@hapi/boom'; +import { strictKeysRt } from '@kbn/io-ts-utils'; +import { RouteParamsRT } from './typings'; + +interface KibanaRequestParams { + body: unknown; + query: unknown; + params: unknown; +} + +export function decodeRequestParams( + params: KibanaRequestParams, + paramsRt: T +): t.OutputOf { + const paramMap = omitBy( + { + path: params.params, + body: params.body, + query: params.query, + }, + (val) => val === null || val === undefined || (isPlainObject(val) && isEmpty(val)) + ); + + // decode = validate + const result = strictKeysRt(paramsRt).decode(paramMap); + + if (isLeft(result)) { + throw Boom.badRequest(PathReporter.report(result)[0]); + } + + return result.right; +} diff --git a/packages/kbn-server-route-repository/src/format_request.ts b/packages/kbn-server-route-repository/src/format_request.ts new file mode 100644 index 0000000000000..49004a78ce0e0 --- /dev/null +++ b/packages/kbn-server-route-repository/src/format_request.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseEndpoint } from './parse_endpoint'; + +export function formatRequest(endpoint: string, pathParams: Record = {}) { + const { method, pathname: rawPathname } = parseEndpoint(endpoint); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/index.ts b/packages/kbn-server-route-repository/src/index.ts new file mode 100644 index 0000000000000..23621c5b213bc --- /dev/null +++ b/packages/kbn-server-route-repository/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createServerRouteRepository } from './create_server_route_repository'; +export { createServerRouteFactory } from './create_server_route_factory'; +export { formatRequest } from './format_request'; +export { parseEndpoint } from './parse_endpoint'; +export { decodeRequestParams } from './decode_request_params'; +export { routeValidationObject } from './route_validation_object'; +export { + RouteRepositoryClient, + ReturnOf, + EndpointOf, + ClientRequestParamsOf, + DecodedRequestParamsOf, + ServerRouteRepository, + ServerRoute, + RouteParamsRT, +} from './typings'; diff --git a/packages/kbn-server-route-repository/src/parse_endpoint.ts b/packages/kbn-server-route-repository/src/parse_endpoint.ts new file mode 100644 index 0000000000000..fd40489b0f4a5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/parse_endpoint.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint(endpoint: string) { + const parts = endpoint.split(' '); + + const method = parts[0].trim().toLowerCase() as Method; + const pathname = parts[1].trim(); + + if (!['get', 'post', 'put', 'delete'].includes(method)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/route_validation_object.ts b/packages/kbn-server-route-repository/src/route_validation_object.ts new file mode 100644 index 0000000000000..550be8d20d446 --- /dev/null +++ b/packages/kbn-server-route-repository/src/route_validation_object.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema } from '@kbn/config-schema'; + +const anyObject = schema.object({}, { unknowns: 'allow' }); + +export const routeValidationObject = { + // `body` can be null, but `validate` expects non-nullable types + // if any validation is defined. Not having validation currently + // means we don't get the payload. See + // https://github.com/elastic/kibana/issues/50179 + body: schema.nullable(anyObject), + params: anyObject, + query: anyObject, +}; diff --git a/packages/kbn-server-route-repository/src/test_types.ts b/packages/kbn-server-route-repository/src/test_types.ts new file mode 100644 index 0000000000000..c9015e19b82f8 --- /dev/null +++ b/packages/kbn-server-route-repository/src/test_types.ts @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { createServerRouteRepository } from './create_server_route_repository'; +import { decodeRequestParams } from './decode_request_params'; +import { EndpointOf, ReturnOf, RouteRepositoryClient } from './typings'; + +function assertType(value: TShape) { + return value; +} + +// Generic arguments for createServerRouteRepository should be set, +// if not, registering routes should not be allowed +createServerRouteRepository().add({ + // @ts-expect-error + endpoint: 'any_endpoint', + // @ts-expect-error + handler: async ({ params }) => {}, +}); + +// If a params codec is not set, its type should not be available in +// the request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_without_params', + handler: async (resources) => { + // @ts-expect-error Argument of type '{}' is not assignable to parameter of type '{ params: any; }'. + assertType<{ params: any }>(resources); + }, +}); + +// If a params codec is set, its type _should_ be available in the +// request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +// Resources should be passed to the request handler. +createServerRouteRepository<{ context: { getSpaceId: () => string } }, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async ({ context }) => { + const spaceId = context.getSpaceId(); + assertType(spaceId); + }, +}); + +// Create options are available when registering a route. +createServerRouteRepository<{}, { options: { tags: string[] } }>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + options: { + tags: [], + }, + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +const repository = createServerRouteRepository<{}, {}>() + .add({ + endpoint: 'endpoint_without_params', + handler: async () => { + return { + noParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + yesParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_optional_params', + params: t.partial({ + query: t.partial({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + someParamsForMe: true, + }; + }, + }); + +type TestRepository = typeof repository; + +// EndpointOf should return all valid endpoints of a repository + +assertType>>([ + 'endpoint_with_params', + 'endpoint_without_params', + 'endpoint_with_optional_params', +]); + +// @ts-expect-error Type '"this_endpoint_does_not_exist"' is not assignable to type '"endpoint_without_params" | "endpoint_with_params" | "endpoint_with_optional_params"' +assertType>>(['this_endpoint_does_not_exist']); + +// ReturnOf should return the return type of a request handler. + +assertType>({ + noParamsForMe: true, +}); + +const noParamsInvalid: ReturnOf = { + // @ts-expect-error type '{ paramsForMe: boolean; }' is not assignable to type '{ noParamsForMe: boolean; }'. + paramsForMe: true, +}; + +// RouteRepositoryClient + +type TestClient = RouteRepositoryClient; + +const client: TestClient = {} as any; + +// It should respect any additional create options. + +// @ts-expect-error Property 'timeout' is missing +client({ + endpoint: 'endpoint_without_params', +}); + +client({ + endpoint: 'endpoint_without_params', + timeout: 1, +}); + +// It does not allow params for routes without a params codec +client({ + endpoint: 'endpoint_without_params', + // @ts-expect-error Object literal may only specify known properties, and 'params' does not exist in type + params: {}, + timeout: 1, +}); + +// It requires params for routes with a params codec +client({ + endpoint: 'endpoint_with_params', + params: { + // @ts-expect-error property 'serviceName' is missing in type '{}' + path: {}, + }, + timeout: 1, +}); + +// Params are optional if the codec has no required keys +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, +}); + +// If optional, an error will still occur if the params do not match +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, + params: { + // @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type + path: '', + }, +}); + +// The return type is correctly inferred +client({ + endpoint: 'endpoint_with_params', + params: { + path: { + serviceName: '', + }, + }, + timeout: 1, +}).then((res) => { + assertType<{ + noParamsForMe: boolean; + // @ts-expect-error Property 'noParamsForMe' is missing in type + }>(res); + + assertType<{ + yesParamsForMe: boolean; + }>(res); +}); + +// decodeRequestParams should return the type of the codec that is passed +assertType<{ path: { serviceName: string } }>( + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); + +assertType<{ path: { serviceName: boolean } }>( + // @ts-expect-error The types of 'path.serviceName' are incompatible between these types. + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); diff --git a/packages/kbn-server-route-repository/src/typings.ts b/packages/kbn-server-route-repository/src/typings.ts new file mode 100644 index 0000000000000..c27f67c71e88b --- /dev/null +++ b/packages/kbn-server-route-repository/src/typings.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { RequiredKeys } from 'utility-types'; + +type MaybeOptional }> = RequiredKeys< + T['params'] +> extends never + ? { params?: T['params'] } + : { params: T['params'] }; + +type WithoutIncompatibleMethods = Omit & { + encode: t.Encode; + asEncoder: () => t.Encoder; +}; + +export type RouteParamsRT = WithoutIncompatibleMethods< + t.Type<{ + path?: any; + query?: any; + body?: any; + }> +>; + +export interface RouteState { + [endpoint: string]: ServerRoute; +} + +export type ServerRouteHandlerResources = Record; +export type ServerRouteCreateOptions = Record; + +export type ServerRoute< + TEndpoint extends string, + TRouteParamsRT extends RouteParamsRT | undefined, + TRouteHandlerResources extends ServerRouteHandlerResources, + TReturnType, + TRouteCreateOptions extends ServerRouteCreateOptions +> = { + endpoint: TEndpoint; + params?: TRouteParamsRT; + handler: ({}: TRouteHandlerResources & + (TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {})) => Promise; +} & TRouteCreateOptions; + +export interface ServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions = ServerRouteCreateOptions, + TRouteState extends RouteState = RouteState +> { + add< + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined + >( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > + ): ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & + { + [key in TEndpoint]: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + >; + } + >; + merge< + TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions + > + >( + repository: TServerRouteRepository + ): TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + infer TRouteStateToMerge + > + ? ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & TRouteStateToMerge + > + : never; + getRoutes: () => Array< + ServerRoute + >; +} + +type ClientRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.OutputOf; + }> + : {}; + +type DecodedRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.TypeOf; + }> + : {}; + +export type EndpointOf< + TServerRouteRepository extends ServerRouteRepository +> = TServerRouteRepository extends ServerRouteRepository + ? keyof TRouteState + : never; + +export type ReturnOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + any, + any, + infer TReturnType, + ServerRouteCreateOptions + > + ? TReturnType + : never + : never + : never; + +export type DecodedRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {} + : never + : never + : never; + +export type ClientRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? ClientRequestParamsOfType + : {} + : never + : never + : never; + +export type RouteRepositoryClient< + TServerRouteRepository extends ServerRouteRepository, + TAdditionalClientOptions extends Record +> = >( + options: { + endpoint: TEndpoint; + } & ClientRequestParamsOf & + TAdditionalClientOptions +) => Promise>; diff --git a/packages/kbn-server-route-repository/tsconfig.json b/packages/kbn-server-route-repository/tsconfig.json new file mode 100644 index 0000000000000..8f1e72172c675 --- /dev/null +++ b/packages/kbn-server-route-repository/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-server-route-repository/src", + "types": [ + "jest", + "node" + ], + "noUnusedLocals": false + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-ui-shared-deps/polyfills.js b/packages/kbn-ui-shared-deps/polyfills.js index abbf911cfc8fc..a9ec32023f2bf 100644 --- a/packages/kbn-ui-shared-deps/polyfills.js +++ b/packages/kbn-ui-shared-deps/polyfills.js @@ -8,7 +8,6 @@ require('core-js/stable'); require('regenerator-runtime/runtime'); -require('custom-event-polyfill'); if (typeof window.Event === 'object') { // IE11 doesn't support unknown event types, required by react-use @@ -17,6 +16,4 @@ if (typeof window.Event === 'object') { } require('whatwg-fetch'); -require('abortcontroller-polyfill/dist/polyfill-patch-fetch'); -require('./vendor/childnode_remove_polyfill'); require('symbol-observable'); diff --git a/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js b/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js deleted file mode 100644 index d8818fe809ccb..0000000000000 --- a/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable @kbn/eslint/require-license-header */ - -/* @notice - * This product bundles childnode-remove which is available under a - * "MIT" license. - * - * The MIT License (MIT) - * - * Copyright (c) 2016-present, jszhou - * https://github.com/jserz/js_piece - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -/* eslint-disable */ - -(function (arr) { - arr.forEach(function (item) { - if (item.hasOwnProperty('remove')) { - return; - } - Object.defineProperty(item, 'remove', { - configurable: true, - enumerable: true, - writable: true, - value: function remove() { - if (this.parentNode !== null) - this.parentNode.removeChild(this); - } - }); - }); -})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/src/plugins/data/common/kbn_field_types/types.ts b/src/plugins/data/common/kbn_field_types/types.ts index c46e5c5266f55..e6f815e058ce3 100644 --- a/src/plugins/data/common/kbn_field_types/types.ts +++ b/src/plugins/data/common/kbn_field_types/types.ts @@ -80,4 +80,5 @@ export enum KBN_FIELD_TYPES { OBJECT = 'object', NESTED = 'nested', HISTOGRAM = 'histogram', + MISSING = 'missing', } diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts index 297af560081b1..3ce528e6ed893 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.test.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts @@ -230,7 +230,7 @@ describe('AggConfigs', () => { describe('#toDsl', () => { beforeEach(() => { indexPattern = stubIndexPattern as IndexPattern; - indexPattern.fields.getByName = (name) => (name as unknown) as IndexPatternField; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); }); it('uses the sorted aggs', () => { diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 4e278d5872a3e..56e720d237c45 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -16,16 +16,33 @@ import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; import { BUCKET_TYPES } from './bucket_agg_types'; import { IBucketAggConfig } from './bucket_agg_type'; import { mockAggTypesRegistry } from '../test_helpers'; +import type { IndexPatternField } from '../../../index_patterns'; +import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern'; const indexPattern = { id: '1234', title: 'logstash-*', fields: [ { - name: 'field', + name: 'machine.os.raw', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'geo.src', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, }, ], -} as any; +} as IndexPattern; + +indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); const singleTerm = { aggs: [ diff --git a/src/plugins/data/common/search/aggs/buckets/terms.test.ts b/src/plugins/data/common/search/aggs/buckets/terms.test.ts index bb34d7ede453c..09dfbb28a4e53 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.test.ts @@ -10,6 +10,8 @@ import { AggConfigs } from '../agg_configs'; import { METRIC_TYPES } from '../metrics'; import { mockAggTypesRegistry } from '../test_helpers'; import { BUCKET_TYPES } from './bucket_agg_types'; +import type { IndexPatternField } from '../../../index_patterns'; +import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern'; describe('Terms Agg', () => { describe('order agg editor UI', () => { @@ -17,16 +19,44 @@ describe('Terms Agg', () => { const indexPattern = { id: '1234', title: 'logstash-*', - fields: { - getByName: () => field, - filter: () => [field], - }, - } as any; + fields: [ + { + name: 'field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'string_field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'empty_number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + } as IndexPattern; - const field = { - name: 'field', - indexPattern, - }; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + indexPattern.fields.filter = () => indexPattern.fields; return new AggConfigs( indexPattern, @@ -207,16 +237,28 @@ describe('Terms Agg', () => { const indexPattern = { id: '1234', title: 'logstash-*', - fields: { - getByName: () => field, - filter: () => [field], - }, - } as any; + fields: [ + { + name: 'string_field', + type: 'string', + esTypes: ['string'], + aggregatable: true, + filterable: true, + searchable: true, + }, + { + name: 'number_field', + type: 'number', + esTypes: ['number'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + } as IndexPattern; - const field = { - name: 'field', - indexPattern, - }; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + indexPattern.fields.filter = () => indexPattern.fields; const aggConfigs = new AggConfigs( indexPattern, diff --git a/src/plugins/data/common/search/aggs/param_types/field.ts b/src/plugins/data/common/search/aggs/param_types/field.ts index 2d3ff8f5fdba8..62dac9831211a 100644 --- a/src/plugins/data/common/search/aggs/param_types/field.ts +++ b/src/plugins/data/common/search/aggs/param_types/field.ts @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import { IAggConfig } from '../agg_config'; -import { SavedObjectNotFound } from '../../../../../../plugins/kibana_utils/common'; +import { + SavedFieldNotFound, + SavedFieldTypeInvalidForAgg, +} from '../../../../../../plugins/kibana_utils/common'; import { BaseParamType } from './base'; import { propFilter } from '../utils'; import { KBN_FIELD_TYPES } from '../../../kbn_field_types/types'; @@ -47,13 +50,49 @@ export class FieldParamType extends BaseParamType { ); } - if (field.scripted) { + if (field.type === KBN_FIELD_TYPES.MISSING) { + throw new SavedFieldNotFound( + i18n.translate( + 'data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage', + { + defaultMessage: + 'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.', + values: { + fieldParameter: field.name, + }, + } + ) + ); + } + + const validField = this.getAvailableFields(aggConfig).find( + (f: any) => f.name === field.name + ); + + if (!validField) { + throw new SavedFieldTypeInvalidForAgg( + i18n.translate( + 'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage', + { + defaultMessage: + 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.', + values: { + fieldParameter: field.name, + aggType: aggConfig?.type?.title, + indexPatternTitle: aggConfig.getIndexPattern().title, + }, + } + ) + ); + } + + if (validField.scripted) { output.params.script = { - source: field.script, - lang: field.lang, + source: validField.script, + lang: validField.lang, }; } else { - output.params.field = field.name; + output.params.field = validField.name; } }; } @@ -69,28 +108,15 @@ export class FieldParamType extends BaseParamType { const field = aggConfig.getIndexPattern().fields.getByName(fieldName); if (!field) { - throw new SavedObjectNotFound('index-pattern-field', fieldName); - } - - const validField = this.getAvailableFields(aggConfig).find((f: any) => f.name === fieldName); - if (!validField) { - throw new Error( - i18n.translate( - 'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage', - { - defaultMessage: - 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.', - values: { - fieldParameter: fieldName, - aggType: aggConfig?.type?.title, - indexPatternTitle: aggConfig.getIndexPattern().title, - }, - } - ) - ); + return new IndexPatternField({ + type: KBN_FIELD_TYPES.MISSING, + name: fieldName, + searchable: false, + aggregatable: false, + }); } - return validField; + return field; }; } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index db7f814a83f79..05925f097de24 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1786,6 +1786,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) IP_RANGE = "ip_range", // (undocumented) + MISSING = "missing", + // (undocumented) MURMUR3 = "murmur3", // (undocumented) NESTED = "nested", diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index 466cc8c3de0b7..4e12f11668734 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -17,6 +17,16 @@ @include kbnThemeStyle('v8') { background-color: $euiFormBackgroundColor; + border-radius: $euiFormControlBorderRadius; + + &.kbnQueryBar__textareaWrap--hasPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &.kbnQueryBar__textareaWrap--hasAppend { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } } @@ -35,8 +45,16 @@ } @include kbnThemeStyle('v8') { - border-radius: 0; padding-bottom: $euiSizeS + 1px; + + &.kbnQueryBar__textarea--hasPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &.kbnQueryBar__textarea--hasAppend { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } &:not(.kbnQueryBar__textarea--autoHeight):not(:invalid) { diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 900a4ab7d7eb7..0f660f87266fd 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -682,7 +682,14 @@ export default class QueryStringInputUI extends Component { ); const inputClassName = classNames( 'kbnQueryBar__textarea', - this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null + this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null, + this.props.prepend ? 'kbnQueryBar__textarea--hasPrepend' : null, + !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textarea--hasAppend' : null + ); + const inputWrapClassName = classNames( + 'euiFormControlLayout__childrenWrapper kbnQueryBar__textareaWrap', + this.props.prepend ? 'kbnQueryBar__textareaWrap--hasPrepend' : null, + !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textareaWrap--hasAppend' : null ); return ( @@ -711,7 +718,7 @@ export default class QueryStringInputUI extends Component { >
> { + public getUpdated$(): Readonly> { return merge(this.getInput$().pipe(skip(1)), this.getOutput$().pipe(skip(1))).pipe( - debounceTime(0), - mapTo(undefined) + debounceTime(0) ); } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index b9719542adc81..3f0907acabdfa 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -282,7 +282,7 @@ export abstract class Embeddable>; + getUpdated$(): Readonly>; // (undocumented) readonly id: string; // (undocumented) diff --git a/src/plugins/kibana_utils/common/errors/errors.ts b/src/plugins/kibana_utils/common/errors/errors.ts index 7a9495cc8f413..7f3efc6d9571f 100644 --- a/src/plugins/kibana_utils/common/errors/errors.ts +++ b/src/plugins/kibana_utils/common/errors/errors.ts @@ -32,7 +32,7 @@ export class DuplicateField extends KbnError { export class SavedObjectNotFound extends KbnError { public savedObjectType: string; public savedObjectId?: string; - constructor(type: string, id?: string, link?: string) { + constructor(type: string, id?: string, link?: string, customMessage?: string) { const idMsg = id ? ` (id: ${id})` : ''; let message = `Could not locate that ${type}${idMsg}`; @@ -40,13 +40,31 @@ export class SavedObjectNotFound extends KbnError { message += `, [click here to re-create it](${link})`; } - super(message); + super(customMessage || message); this.savedObjectType = type; this.savedObjectId = id; } } +/** + * A saved field doesn't exist anymore + */ +export class SavedFieldNotFound extends KbnError { + constructor(message: string) { + super(message); + } +} + +/** + * A saved field type isn't compatible with aggregation + */ +export class SavedFieldTypeInvalidForAgg extends KbnError { + constructor(message: string) { + super(message); + } +} + /** * This error is for scenarios where a saved object is detected that has invalid JSON properties. * There was a scenario where we were importing objects with double-encoded JSON, and the system diff --git a/src/plugins/vis_default_editor/public/_default.scss b/src/plugins/vis_default_editor/public/_default.scss index c412b9d915e55..56c6a0f0f63f6 100644 --- a/src/plugins/vis_default_editor/public/_default.scss +++ b/src/plugins/vis_default_editor/public/_default.scss @@ -1,6 +1,4 @@ .visEditor--default { - // height: 1px is in place to make editor children take their height in the parent - height: 1px; flex: 1 1 auto; display: flex; } @@ -80,6 +78,7 @@ .visEditor__collapsibleSidebar { width: 100% !important; // force the editor to take 100% width + flex-grow: 0; } .visEditor__collapsibleSidebar-isClosed { @@ -91,8 +90,10 @@ } .visEditor__visualization__wrapper { - // force the visualization to take 100% width and height. + // force the visualization to take 100% width. width: 100% !important; - height: 100% !important; + flex: 1; + display: flex; + flex-direction: column; } } diff --git a/src/plugins/vis_default_editor/public/components/controls/field.test.tsx b/src/plugins/vis_default_editor/public/components/controls/field.test.tsx index 94f767510c4bd..277804567c2b7 100644 --- a/src/plugins/vis_default_editor/public/components/controls/field.test.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/field.test.tsx @@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils'; import { mount, shallow, ReactWrapper } from 'enzyme'; import { EuiComboBoxProps, EuiComboBox } from '@elastic/eui'; -import { IAggConfig, IndexPatternField } from 'src/plugins/data/public'; +import { IAggConfig, IndexPatternField, AggParam } from 'src/plugins/data/public'; import { ComboBoxGroupedOptions } from '../../utils'; import { FieldParamEditor, FieldParamEditorProps } from './field'; import { EditorVisState } from '../sidebar/state/reducers'; @@ -42,7 +42,7 @@ describe('FieldParamEditor component', () => { setTouched = jest.fn(); onChange = jest.fn(); - field = { displayName: 'bytes' } as IndexPatternField; + field = { displayName: 'bytes', type: 'bytes' } as IndexPatternField; option = { label: 'bytes', target: field }; indexedFields = [ { @@ -52,7 +52,16 @@ describe('FieldParamEditor component', () => { ]; defaultProps = { - agg: {} as IAggConfig, + agg: { + type: { + params: [ + ({ + name: 'field', + filterFieldTypes: ['bytes'], + } as unknown) as AggParam, + ], + }, + } as IAggConfig, aggParam: { name: 'field', type: 'field', diff --git a/src/plugins/vis_default_editor/public/components/controls/field.tsx b/src/plugins/vis_default_editor/public/components/controls/field.tsx index 95843dc6ae3a8..f8db2d89888a2 100644 --- a/src/plugins/vis_default_editor/public/components/controls/field.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/field.tsx @@ -13,7 +13,13 @@ import useMount from 'react-use/lib/useMount'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AggParam, IAggConfig, IFieldParamType, IndexPatternField } from 'src/plugins/data/public'; +import { + AggParam, + IAggConfig, + IFieldParamType, + IndexPatternField, + KBN_FIELD_TYPES, +} from '../../../../../plugins/data/public'; import { formatListAsProse, parseCommaSeparatedList, useValidation } from './utils'; import { AggParamEditorProps } from '../agg_param_props'; import { ComboBoxGroupedOptions } from '../../utils'; @@ -55,6 +61,7 @@ function FieldParamEditor({ } }; const errors = customError ? [customError] : []; + let showErrorMessageImmediately = false; if (!indexedFields.length) { errors.push( @@ -69,9 +76,38 @@ function FieldParamEditor({ ); } + if (value && value.type === KBN_FIELD_TYPES.MISSING) { + errors.push( + i18n.translate('visDefaultEditor.controls.field.fieldIsNotExists', { + defaultMessage: + 'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.', + values: { + fieldParameter: value.name, + }, + }) + ); + showErrorMessageImmediately = true; + } else if ( + value && + !getFieldTypes(agg).find((type: string) => type === value.type || type === '*') + ) { + errors.push( + i18n.translate('visDefaultEditor.controls.field.invalidFieldForAggregation', { + defaultMessage: + 'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with this aggregation. Please select a new field.', + values: { + fieldParameter: value?.name, + indexPatternTitle: agg.getIndexPattern && agg.getIndexPattern().title, + }, + }) + ); + showErrorMessageImmediately = true; + } + const isValid = !!value && !errors.length && !isDirty; // we show an error message right away if there is no compatible fields - const showErrorMessage = (showValidation || !indexedFields.length) && !isValid; + const showErrorMessage = + (showValidation || !indexedFields.length || showErrorMessageImmediately) && !isValid; useValidation(setValidity, isValid); useMount(() => { @@ -122,10 +158,14 @@ function FieldParamEditor({ } function getFieldTypesString(agg: IAggConfig) { + return formatListAsProse(getFieldTypes(agg), { inclusive: false }); +} + +function getFieldTypes(agg: IAggConfig) { const param = get(agg, 'type.params', []).find((p: AggParam) => p.name === 'field') || ({} as IFieldParamType); - return formatListAsProse(parseCommaSeparatedList(param.filterFieldTypes), { inclusive: false }); + return parseCommaSeparatedList(param.filterFieldTypes || []); } export { FieldParamEditor }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index efb166c8975bb..3bb52eb15758a 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -149,8 +149,9 @@ export class VisualizeEmbeddable } this.subscriptions.push( - this.getUpdated$().subscribe(() => { + this.getUpdated$().subscribe((value) => { const isDirty = this.handleChanges(); + if (isDirty && this.handler) { this.updateHandler(); } diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index cc0f3ce2afae5..9eda709e58c3e 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -18,8 +18,17 @@ import { SavedObject } from 'src/plugins/saved_objects/public'; import { cloneDeep } from 'lodash'; import { ExpressionValueError } from 'src/plugins/expressions/public'; import { createSavedSearchesLoader } from '../../../../discover/public'; +import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '../../../../kibana_utils/common'; import { VisualizeServices } from '../types'; +function isErrorRelatedToRuntimeFields(error: ExpressionValueError['error']) { + const originalError = error.original || error; + return ( + originalError instanceof SavedFieldNotFound || + originalError instanceof SavedFieldTypeInvalidForAgg + ); +} + const createVisualizeEmbeddableAndLinkSavedSearch = async ( vis: Vis, visualizeServices: VisualizeServices @@ -37,7 +46,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( })) as VisualizeEmbeddableContract; embeddableHandler.getOutput$().subscribe((output) => { - if (output.error) { + if (output.error && !isErrorRelatedToRuntimeFields(output.error)) { data.search.showError( ((output.error as unknown) as ExpressionValueError['error']).original || output.error ); diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts index 64d61996495d7..965951bfbd88d 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts @@ -11,13 +11,12 @@ import { EventEmitter } from 'events'; import { parse } from 'query-string'; import { i18n } from '@kbn/i18n'; -import { redirectWhenMissing } from '../../../../../kibana_utils/public'; - import { getVisualizationInstance } from '../get_visualization_instance'; import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs'; import { SavedVisInstance, VisualizeServices, IEditorController } from '../../types'; import { VisualizeConstants } from '../../visualize_constants'; import { getVisEditorsRegistry } from '../../../services'; +import { redirectToSavedObjectPage } from '../utils'; /** * This effect is responsible for instantiating a saved vis or creating a new one @@ -43,9 +42,7 @@ export const useSavedVisInstance = ( chrome, history, dashboard, - setActiveUrl, toastNotifications, - http: { basePath }, stateTransferService, application: { navigateToApp }, } = services; @@ -131,27 +128,8 @@ export const useSavedVisInstance = ( visEditorController, }); } catch (error) { - const managementRedirectTarget = { - app: 'management', - path: `kibana/objects/savedVisualizations/${visualizationIdFromUrl}`, - }; - try { - redirectWhenMissing({ - history, - navigateToApp, - toastNotifications, - basePath, - mapping: { - visualization: VisualizeConstants.LANDING_PAGE_PATH, - search: managementRedirectTarget, - 'index-pattern': managementRedirectTarget, - 'index-pattern-field': managementRedirectTarget, - }, - onBeforeRedirect() { - setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); - }, - })(error); + redirectToSavedObjectPage(services, error, visualizationIdFromUrl); } catch (e) { toastNotifications.addWarning({ title: i18n.translate('visualize.createVisualization.failedToLoadErrorMessage', { diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts index 0e529507f97e3..c906ff5304c90 100644 --- a/src/plugins/visualize/public/application/utils/utils.ts +++ b/src/plugins/visualize/public/application/utils/utils.ts @@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n'; import { ChromeStart, DocLinksStart } from 'kibana/public'; import { Filter } from '../../../../data/public'; +import { redirectWhenMissing } from '../../../../kibana_utils/public'; +import { VisualizeConstants } from '../visualize_constants'; import { VisualizeServices, VisualizeEditorVisInstance } from '../types'; export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => { @@ -58,3 +60,36 @@ export const visStateToEditorState = ( linked: savedVis && savedVis.id ? !!savedVis.savedSearchId : !!savedVisState.savedSearchId, }; }; + +export const redirectToSavedObjectPage = ( + services: VisualizeServices, + error: any, + savedVisualizationsId?: string +) => { + const { + history, + setActiveUrl, + toastNotifications, + http: { basePath }, + application: { navigateToApp }, + } = services; + const managementRedirectTarget = { + app: 'management', + path: `kibana/objects/savedVisualizations/${savedVisualizationsId}`, + }; + redirectWhenMissing({ + history, + navigateToApp, + toastNotifications, + basePath, + mapping: { + visualization: VisualizeConstants.LANDING_PAGE_PATH, + search: managementRedirectTarget, + 'index-pattern': managementRedirectTarget, + 'index-pattern-field': managementRedirectTarget, + }, + onBeforeRedirect() { + setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); + }, + })(error); +}; diff --git a/x-pack/examples/alerting_example/server/plugin.ts b/x-pack/examples/alerting_example/server/plugin.ts index db9c996147c94..f6131679874db 100644 --- a/x-pack/examples/alerting_example/server/plugin.ts +++ b/x-pack/examples/alerting_example/server/plugin.ts @@ -33,7 +33,7 @@ export class AlertingExamplePlugin implements Plugin( ? t.failure(input, context) : t.success(epochDate); }), - (a) => { - const d = new Date(a); - return d.toISOString(); - } + (output) => new Date(output).toISOString() ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx index 6a6db40892e10..407f460f25ad3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx @@ -14,15 +14,18 @@ import { act, waitFor, } from '@testing-library/react'; -import * as apmApi from '../../../../../../services/rest/createCallApmApi'; +import { + getCallApmApiSpy, + CallApmApiSpy, +} from '../../../../../../services/rest/callApmApiSpy'; export const removeExternalLinkText = (str: string) => str.replace(/\(opens in a new tab or window\)/g, ''); describe('LinkPreview', () => { - let callApmApiSpy: jest.SpyInstance; + let callApmApiSpy: CallApmApiSpy; beforeAll(() => { - callApmApiSpy = jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({ + callApmApiSpy = getCallApmApiSpy().mockResolvedValue({ transaction: { id: 'foo' }, }); }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 77835afef863a..7d119b8c406da 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -8,6 +8,7 @@ import { fireEvent, render, RenderResult } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; +import { getCallApmApiSpy } from '../../../../../services/rest/callApmApiSpy'; import { CustomLinkOverview } from '.'; import { License } from '../../../../../../../licensing/common/license'; import { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; @@ -17,7 +18,6 @@ import { } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../../../context/license/license_context'; import * as hooks from '../../../../../hooks/use_fetcher'; -import * as apmApi from '../../../../../services/rest/createCallApmApi'; import { expectTextsInDocument, expectTextsNotInDocument, @@ -43,7 +43,7 @@ function getMockAPMContext({ canSave }: { canSave: boolean }) { describe('CustomLink', () => { beforeAll(() => { - jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({}); + getCallApmApiSpy().mockResolvedValue({}); }); afterAll(() => { jest.resetAllMocks(); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index b30faac7a65af..c6ed4e640693f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -22,9 +22,12 @@ import * as useTransactionBreakdownHooks from '../../shared/charts/transaction_b import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; import { waitFor } from '@testing-library/dom'; -import * as callApmApiModule from '../../../services/rest/createCallApmApi'; import * as useApmServiceContextHooks from '../../../context/apm_service/use_apm_service_context'; import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { + getCallApmApiSpy, + getCreateCallApmApiSpy, +} from '../../../services/rest/callApmApiSpy'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -83,10 +86,10 @@ describe('ServiceOverview', () => { /* eslint-disable @typescript-eslint/naming-convention */ const calls = { 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics': { - error_groups: [], + error_groups: [] as any[], }, 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { - transactionGroups: [], + transactionGroups: [] as any[], totalTransactionGroups: 0, isAggregationAccurate: true, }, @@ -95,19 +98,17 @@ describe('ServiceOverview', () => { }; /* eslint-enable @typescript-eslint/naming-convention */ - jest - .spyOn(callApmApiModule, 'createCallApmApi') - .mockImplementation(() => {}); - - const callApmApi = jest - .spyOn(callApmApiModule, 'callApmApi') - .mockImplementation(({ endpoint }) => { + const callApmApiSpy = getCallApmApiSpy().mockImplementation( + ({ endpoint }) => { const response = calls[endpoint as keyof typeof calls]; return response ? Promise.resolve(response) : Promise.reject(`Response for ${endpoint} is not defined`); - }); + } + ); + + getCreateCallApmApiSpy().mockImplementation(() => callApmApiSpy as any); jest .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') .mockReturnValue({ @@ -124,7 +125,7 @@ describe('ServiceOverview', () => { ); await waitFor(() => - expect(callApmApi).toHaveBeenCalledTimes(Object.keys(calls).length) + expect(callApmApiSpy).toHaveBeenCalledTimes(Object.keys(calls).length) ); expect((await findAllByText('Latency')).length).toBeGreaterThan(0); diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts index 29fabc51fd582..00447607cf787 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts @@ -10,10 +10,10 @@ import { fetchObservabilityOverviewPageData, getHasData, } from './apm_observability_overview_fetchers'; -import * as createCallApmApi from './createCallApmApi'; +import { getCallApmApiSpy } from './callApmApiSpy'; describe('Observability dashboard data', () => { - const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const callApmApiMock = getCallApmApiSpy(); const params = { absoluteTime: { start: moment('2020-07-02T13:25:11.629Z').valueOf(), @@ -84,7 +84,7 @@ describe('Observability dashboard data', () => { callApmApiMock.mockImplementation(() => Promise.resolve({ serviceCount: 0, - transactionPerMinute: { value: null, timeseries: [] }, + transactionPerMinute: { value: null, timeseries: [] as any }, }) ); const response = await fetchObservabilityOverviewPageData(params); diff --git a/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts new file mode 100644 index 0000000000000..ba9f740e06d0d --- /dev/null +++ b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as createCallApmApi from './createCallApmApi'; +import type { AbstractAPMClient } from './createCallApmApi'; + +export type CallApmApiSpy = jest.SpyInstance< + Promise, + Parameters +>; + +export type CreateCallApmApiSpy = jest.SpyInstance; + +export const getCreateCallApmApiSpy = () => + (jest.spyOn( + createCallApmApi, + 'createCallApmApi' + ) as unknown) as CreateCallApmApiSpy; +export const getCallApmApiSpy = () => + (jest.spyOn(createCallApmApi, 'callApmApi') as unknown) as CallApmApiSpy; diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index b0cce3296fe21..0e82d70faf1e1 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -6,30 +6,68 @@ */ import { CoreSetup, CoreStart } from 'kibana/public'; -import { parseEndpoint } from '../../../common/apm_api/parse_endpoint'; +import * as t from 'io-ts'; +import type { + ClientRequestParamsOf, + EndpointOf, + ReturnOf, + RouteRepositoryClient, + ServerRouteRepository, + ServerRoute, +} from '@kbn/server-route-repository'; +import { formatRequest } from '@kbn/server-route-repository/target/format_request'; import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { APMAPI } from '../../../server/routes/create_apm_api'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { Client } from '../../../server/routes/typings'; - -export type APMClient = Client; -export type AutoAbortedAPMClient = Client; +import type { + APMServerRouteRepository, + InspectResponse, + APMRouteHandlerResources, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server'; export type APMClientOptions = Omit< FetchOptions, 'query' | 'body' | 'pathname' | 'signal' > & { - endpoint: string; signal: AbortSignal | null; - params?: { - body?: any; - query?: Record; - path?: Record; - }; }; +export type APMClient = RouteRepositoryClient< + APMServerRouteRepository, + APMClientOptions +>; + +export type AutoAbortedAPMClient = RouteRepositoryClient< + APMServerRouteRepository, + Omit +>; + +export type APIReturnType< + TEndpoint extends EndpointOf +> = ReturnOf & { + _inspect?: InspectResponse; +}; + +export type APIEndpoint = EndpointOf; + +export type APIClientRequestParamsOf< + TEndpoint extends EndpointOf +> = ClientRequestParamsOf; + +export type AbstractAPMRepository = ServerRouteRepository< + APMRouteHandlerResources, + {}, + Record< + string, + ServerRoute + > +>; + +export type AbstractAPMClient = RouteRepositoryClient< + AbstractAPMRepository, + APMClientOptions +>; + export let callApmApi: APMClient = () => { throw new Error( 'callApmApi has to be initialized before used. Call createCallApmApi first.' @@ -37,9 +75,13 @@ export let callApmApi: APMClient = () => { }; export function createCallApmApi(core: CoreStart | CoreSetup) { - callApmApi = ((options: APMClientOptions) => { - const { endpoint, params, ...opts } = options; - const { method, pathname } = parseEndpoint(endpoint, params?.path); + callApmApi = ((options) => { + const { endpoint, ...opts } = options; + const { params } = (options as unknown) as { + params?: Partial>; + }; + + const { method, pathname } = formatRequest(endpoint, params?.path); return callApi(core, { ...opts, @@ -50,10 +92,3 @@ export function createCallApmApi(core: CoreStart | CoreSetup) { }); }) as APMClient; } - -// infer return type from API -export type APIReturnType< - TPath extends keyof APMAPI['_S'] -> = APMAPI['_S'][TPath] extends { ret: any } - ? APMAPI['_S'][TPath]['ret'] - : unknown; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 00910353ac278..9ab56c1a303ea 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -120,5 +120,9 @@ export function mergeConfigs( export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); -export { APMPlugin, APMPluginSetup } from './plugin'; +export { APMPlugin } from './plugin'; +export { APMPluginSetup } from './types'; +export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository'; +export { InspectResponse, APMRouteHandlerResources } from './routes/typings'; + export type { ProcessorEvent } from '../common/processor_event'; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index 1f0aa401bcab0..989297544c78f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -10,7 +10,7 @@ import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; -import { inspectableEsQueriesMap } from '../../../routes/create_api'; +import { inspectableEsQueriesMap } from '../../../routes/register_routes'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts index 45e17c1678518..9d7434d127ead 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { CreateIndexRequest, @@ -13,7 +12,7 @@ import { IndexRequest, } from '@elastic/elasticsearch/api/types'; import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../../routes/typings'; import { ESSearchResponse, ESSearchRequest, @@ -31,11 +30,9 @@ export type APMInternalClient = ReturnType; export function createInternalESClient({ context, + debug, request, -}: { - context: APMRequestHandlerContext; - request: KibanaRequest; -}) { +}: Pick & { debug: boolean }) { const { asInternalUser } = context.core.elasticsearch.client; function callEs({ @@ -53,7 +50,7 @@ export function createInternalESClient({ title: getDebugTitle(request), body: getDebugBody(params, requestType), }), - debug: context.params.query._inspect, + debug, isCalledWithInternalUser: true, request, requestType, diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index c0707d0286180..c0ff0cab88f47 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -7,8 +7,7 @@ import { setupRequest } from './setup_request'; import { APMConfig } from '../..'; -import { APMRequestHandlerContext } from '../../routes/typings'; -import { KibanaRequest } from '../../../../../../src/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ProcessorEvent } from '../../../common/processor_event'; import { PROCESSOR_EVENT } from '../../../common/elasticsearch_fieldnames'; @@ -32,7 +31,7 @@ jest.mock('../index_pattern/get_dynamic_index_pattern', () => ({ }, })); -function getMockRequest() { +function getMockResources() { const esClientMock = { asCurrentUser: { search: jest.fn().mockResolvedValue({ body: {} }), @@ -42,7 +41,7 @@ function getMockRequest() { }, }; - const mockContext = ({ + const mockResources = ({ config: new Proxy( {}, { @@ -54,65 +53,69 @@ function getMockRequest() { _inspect: false, }, }, - core: { - elasticsearch: { - client: esClientMock, - }, - uiSettings: { - client: { - get: jest.fn().mockResolvedValue(false), + context: { + core: { + elasticsearch: { + client: esClientMock, }, - }, - savedObjects: { - client: { - get: jest.fn(), + uiSettings: { + client: { + get: jest.fn().mockResolvedValue(false), + }, + }, + savedObjects: { + client: { + get: jest.fn(), + }, }, }, }, plugins: { ml: undefined, }, - } as unknown) as APMRequestHandlerContext & { - core: { - elasticsearch: { - client: typeof esClientMock; - }; - uiSettings: { - client: { - get: jest.Mock; + request: { + url: '', + events: { + aborted$: { + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + }, + }, + }, + } as unknown) as APMRouteHandlerResources & { + context: { + core: { + elasticsearch: { + client: typeof esClientMock; }; - }; - savedObjects: { - client: { - get: jest.Mock; + uiSettings: { + client: { + get: jest.Mock; + }; + }; + savedObjects: { + client: { + get: jest.Mock; + }; }; }; }; }; - const mockRequest = ({ - url: '', - events: { - aborted$: { - subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), - }, - }, - } as unknown) as KibanaRequest; - - return { mockContext, mockRequest }; + return mockResources; } describe('setupRequest', () => { describe('with default args', () => { it('calls callWithRequest', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction] }, body: { foo: 'bar' }, }); + expect( - mockContext.core.elasticsearch.client.asCurrentUser.search + mockResources.context.core.elasticsearch.client.asCurrentUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -132,14 +135,14 @@ describe('setupRequest', () => { }); it('calls callWithInternalUser', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { internalClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { internalClient } = await setupRequest(mockResources); await internalClient.search({ index: ['apm-*'], body: { foo: 'bar' }, } as any); expect( - mockContext.core.elasticsearch.client.asInternalUser.search + mockResources.context.core.elasticsearch.client.asInternalUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -151,8 +154,8 @@ describe('setupRequest', () => { describe('with a bool filter', () => { it('adds a range filter for `observer.version_major` to the existing filter', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction], @@ -162,8 +165,8 @@ describe('setupRequest', () => { }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -178,8 +181,8 @@ describe('setupRequest', () => { }); it('does not add a range filter for `observer.version_major` if includeLegacyData=true', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search( { apm: { @@ -194,8 +197,8 @@ describe('setupRequest', () => { } ); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -216,15 +219,15 @@ describe('setupRequest', () => { describe('without a bool filter', () => { it('adds a range filter for `observer.version_major`', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.error], }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.body).toEqual({ query: { @@ -241,12 +244,12 @@ describe('without a bool filter', () => { describe('with includeFrozen=false', () => { it('sets `ignore_throttled=true`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return false - mockContext.core.uiSettings.client.get.mockResolvedValue(false); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(false); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { @@ -255,7 +258,7 @@ describe('with includeFrozen=false', () => { }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(true); }); @@ -263,19 +266,19 @@ describe('with includeFrozen=false', () => { describe('with includeFrozen=true', () => { it('sets `ignore_throttled=false`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return true - mockContext.core.uiSettings.client.get.mockResolvedValue(true); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(true); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [] }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(false); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index fff661250c6df..40836cb6635e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -11,7 +11,7 @@ import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { UIFilters } from '../../../typings/ui_filters'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ApmIndicesConfig, getApmIndices, @@ -44,7 +44,7 @@ export interface SetupTimeRange { } interface SetupRequestParams { - query?: { + query: { _inspect?: boolean; /** @@ -64,13 +64,19 @@ type InferSetup = Setup & (TParams extends { query: { start: number } } ? { start: number } : {}) & (TParams extends { query: { end: number } } ? { end: number } : {}); -export async function setupRequest( - context: APMRequestHandlerContext, - request: KibanaRequest -): Promise> { +export async function setupRequest({ + context, + params, + core, + plugins, + request, + config, + logger, +}: APMRouteHandlerResources & { + params: TParams; +}): Promise> { return withApmSpan('setup_request', async () => { - const { config, logger } = context; - const { query } = context.params; + const { query } = params; const [indices, includeFrozen] = await Promise.all([ getApmIndices({ @@ -88,7 +94,7 @@ export async function setupRequest( indices, apmEventClient: createApmEventClient({ esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._inspect, + debug: query._inspect, request, indices, options: { includeFrozen }, @@ -96,11 +102,12 @@ export async function setupRequest( internalClient: createInternalESClient({ context, request, + debug: query._inspect, }), ml: - context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + plugins.ml && isActivePlatinumLicense(context.licensing.license) ? getMlSetup( - context.plugins.ml, + plugins.ml.setup, context.core.savedObjects.client, request ) @@ -118,8 +125,8 @@ export async function setupRequest( } function getMlSetup( - ml: Required['ml'], - savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'], + ml: Required['ml']['setup'], + savedObjectsClient: APMRouteHandlerResources['context']['core']['savedObjects']['client'], request: KibanaRequest ) { return { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts index 19163da449b90..a5340c1220b44 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts @@ -8,21 +8,9 @@ import { createStaticIndexPattern } from './create_static_index_pattern'; import { Setup } from '../helpers/setup_request'; import * as HistoricalAgentData from '../services/get_services/has_historical_agent_data'; -import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; +import { APMConfig } from '../..'; -function getMockContext(config: Record) { - return ({ - config, - core: { - savedObjects: { - client: { - create: jest.fn(), - }, - }, - }, - } as unknown) as APMRequestHandlerContext; -} function getMockSavedObjectsClient() { return ({ create: jest.fn(), @@ -32,13 +20,13 @@ function getMockSavedObjectsClient() { describe('createStaticIndexPattern', () => { it(`should not create index pattern if 'xpack.apm.autocreateApmIndexPattern=false'`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': false, - }); + const savedObjectsClient = getMockSavedObjectsClient(); await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': false, + } as APMConfig, savedObjectsClient, 'default' ); @@ -47,9 +35,6 @@ describe('createStaticIndexPattern', () => { it(`should not create index pattern if no APM data is found`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does not have APM data jest @@ -60,7 +45,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); @@ -69,9 +56,6 @@ describe('createStaticIndexPattern', () => { it(`should create index pattern`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does have APM data jest @@ -82,7 +66,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index b91fb8342a212..e627e9ed1d6cf 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -12,20 +12,18 @@ import { } from '../../../../../../src/plugins/apm_oss/server'; import { hasHistoricalAgentData } from '../services/get_services/has_historical_agent_data'; import { Setup } from '../helpers/setup_request'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; import { withApmSpan } from '../../utils/with_apm_span'; import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( setup: Setup, - context: APMRequestHandlerContext, + config: APMRouteHandlerResources['config'], savedObjectsClient: InternalSavedObjectsClient, spaceId: string | undefined ): Promise { return withApmSpan('create_static_index_pattern', async () => { - const { config } = context; - // don't autocreate APM index pattern if it's been disabled via the config if (!config['xpack.apm.autocreateApmIndexPattern']) { return false; @@ -39,7 +37,7 @@ export async function createStaticIndexPattern( } try { - const apmIndexPatternTitle = getApmIndexPatternTitle(context); + const apmIndexPatternTitle = getApmIndexPatternTitle(config); await withApmSpan('create_index_pattern_saved_object', () => savedObjectsClient.create( 'index-pattern', diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts index 41abe82de8ff2..faec64c798c7d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; -export function getApmIndexPatternTitle(context: APMRequestHandlerContext) { - return context.config['apm_oss.indexPattern']; +export function getApmIndexPatternTitle( + config: APMRouteHandlerResources['config'] +) { + return config['apm_oss.indexPattern']; } diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index 5d5e6eebb4c9f..8bbc22fbf289d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -9,7 +9,7 @@ import { IndexPatternsFetcher, FieldDescriptor, } from '../../../../../../src/plugins/data/server'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { withApmSpan } from '../../utils/with_apm_span'; export interface IndexPatternTitleAndFields { @@ -20,12 +20,12 @@ export interface IndexPatternTitleAndFields { // TODO: this is currently cached globally. In the future we might want to cache this per user export const getDynamicIndexPattern = ({ + config, context, -}: { - context: APMRequestHandlerContext; -}) => { + logger, +}: Pick) => { return withApmSpan('get_dynamic_index_pattern', async () => { - const indexPatternTitle = context.config['apm_oss.indexPattern']; + const indexPatternTitle = config['apm_oss.indexPattern']; const indexPatternsFetcher = new IndexPatternsFetcher( context.core.elasticsearch.client.asCurrentUser @@ -50,7 +50,7 @@ export const getDynamicIndexPattern = ({ } catch (e) { const notExists = e.output?.statusCode === 404; if (notExists) { - context.logger.error( + logger.error( `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` ); return; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index a1587611b0a2a..d8dbc242986a6 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -14,7 +14,7 @@ import { APM_INDICES_SAVED_OBJECT_ID, } from '../../../../common/apm_saved_object_constants'; import { APMConfig } from '../../..'; -import { APMRequestHandlerContext } from '../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../routes/typings'; import { withApmSpan } from '../../../utils/with_apm_span'; type ISavedObjectsClient = Pick; @@ -91,9 +91,8 @@ const APM_UI_INDICES: ApmIndicesName[] = [ export async function getApmIndexSettings({ context, -}: { - context: APMRequestHandlerContext; -}) { + config, +}: Pick) { let apmIndicesSavedObject: PromiseReturnType; try { apmIndicesSavedObject = await getApmIndicesSavedObject( @@ -106,7 +105,7 @@ export async function getApmIndexSettings({ throw error; } } - const apmIndicesConfig = getApmIndicesConfig(context.config); + const apmIndicesConfig = getApmIndicesConfig(config); return APM_UI_INDICES.map((configurationName) => ({ configurationName, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db96794627519..074df7eaafd3c 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { combineLatest, Observable } from 'rxjs'; +import { combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { CoreSetup, @@ -16,22 +16,10 @@ import { Plugin, PluginInitializerContext, } from 'src/core/server'; -import { SpacesPluginSetup } from '../../spaces/server'; +import { mapValues } from 'lodash'; import { APMConfig, APMXPackConfig } from '.'; import { mergeConfigs } from './index'; -import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; -import { ActionsPlugin } from '../../actions/server'; -import { AlertingPlugin } from '../../alerting/server'; -import { CloudSetup } from '../../cloud/server'; -import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; -import { MlPluginSetup } from '../../ml/server'; -import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; @@ -40,23 +28,29 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { createApmApi } from './routes/create_apm_api'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; import { uiSettings } from './ui_settings'; -import type { ApmPluginRequestHandlerContext } from './routes/typings'; - -export interface APMPluginSetup { - config$: Observable; - getApmIndices: () => ReturnType; - createApmEventClient: (params: { - debug?: boolean; - request: KibanaRequest; - context: ApmPluginRequestHandlerContext; - }) => Promise>; -} - -export class APMPlugin implements Plugin { +import type { + ApmPluginRequestHandlerContext, + APMRouteHandlerResources, +} from './routes/typings'; +import { + APMPluginSetup, + APMPluginSetupDependencies, + APMPluginStartDependencies, +} from './types'; +import { registerRoutes } from './routes/register_routes'; +import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; + +export class APMPlugin + implements + Plugin< + APMPluginSetup, + void, + APMPluginSetupDependencies, + APMPluginStartDependencies + > { private currentConfig?: APMConfig; private logger?: Logger; constructor(private readonly initContext: PluginInitializerContext) { @@ -64,22 +58,8 @@ export class APMPlugin implements Plugin { } public setup( - core: CoreSetup, - plugins: { - spaces?: SpacesPluginSetup; - apmOss: APMOSSPluginSetup; - home: HomeServerPluginSetup; - licensing: LicensingPluginSetup; - cloud?: CloudSetup; - usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; - alerting?: AlertingPlugin['setup']; - actions?: ActionsPlugin['setup']; - observability?: ObservabilityPluginSetup; - features: FeaturesPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - } + core: CoreSetup, + plugins: Omit ) { this.logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); @@ -101,11 +81,13 @@ export class APMPlugin implements Plugin { }); } - this.currentConfig = mergeConfigs( + const currentConfig = mergeConfigs( plugins.apmOss.config, this.initContext.config.get() ); + this.currentConfig = currentConfig; + if ( plugins.taskManager && plugins.usageCollection && @@ -122,8 +104,8 @@ export class APMPlugin implements Plugin { } const ossTutorialProvider = plugins.apmOss.getRegisteredTutorialProvider(); - plugins.home.tutorials.unregisterTutorial(ossTutorialProvider); - plugins.home.tutorials.registerTutorial(() => { + plugins.home?.tutorials.unregisterTutorial(ossTutorialProvider); + plugins.home?.tutorials.registerTutorial(() => { const ossPart = ossTutorialProvider({}); if (this.currentConfig!['xpack.apm.ui.enabled'] && ossPart.artifacts) { ossPart.artifacts.application = { @@ -147,10 +129,26 @@ export class APMPlugin implements Plugin { registerFeaturesUsage({ licensingPlugin: plugins.licensing }); - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins, + registerRoutes({ + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + logger: this.logger, + config: currentConfig, + repository: getGlobalApmServerRouteRepository(), + plugins: mapValues(plugins, (value, key) => { + return { + setup: value, + start: () => + core.getStartServices().then((services) => { + const [, pluginsStartContracts] = services; + return pluginsStartContracts[ + key as keyof APMPluginStartDependencies + ]; + }), + }; + }) as APMRouteHandlerResources['plugins'], }); const boundGetApmIndices = async () => diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts index 3bebcd49ec34a..0175860e93d35 100644 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -10,7 +10,8 @@ import { getTransactionDurationChartPreview } from '../../lib/alerts/chart_previ import { getTransactionErrorCountChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_count'; import { getTransactionErrorRateChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_rate'; import { setupRequest } from '../../lib/helpers/setup_request'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; import { rangeRt } from '../default_api_types'; const alertParamsRt = t.intersection([ @@ -29,13 +30,14 @@ const alertParamsRt = t.intersection([ export type AlertParams = t.TypeOf; -export const transactionErrorRateChartPreview = createRoute({ +const transactionErrorRateChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { _inspect, ...alertParams } = params.query; const errorRateChartPreview = await getTransactionErrorRateChartPreview({ setup, @@ -46,13 +48,16 @@ export const transactionErrorRateChartPreview = createRoute({ }, }); -export const transactionErrorCountChartPreview = createRoute({ +const transactionErrorCountChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; + const errorCountChartPreview = await getTransactionErrorCountChartPreview({ setup, alertParams, @@ -62,13 +67,16 @@ export const transactionErrorCountChartPreview = createRoute({ }, }); -export const transactionDurationChartPreview = createRoute({ +const transactionDurationChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; const latencyChartPreview = await getTransactionDurationChartPreview({ alertParams, @@ -78,3 +86,9 @@ export const transactionDurationChartPreview = createRoute({ return { latencyChartPreview }; }, }); + +export const alertsChartPreviewRouteRepository = createApmServerRouteRepository() + .add(transactionErrorRateChartPreview) + .add(transactionDurationChartPreview) + .add(transactionErrorCountChartPreview) + .add(transactionDurationChartPreview); diff --git a/x-pack/plugins/apm/server/routes/correlations.ts b/x-pack/plugins/apm/server/routes/correlations.ts index c7c69e0774822..4728aa2e8d3f6 100644 --- a/x-pack/plugins/apm/server/routes/correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations.ts @@ -14,7 +14,8 @@ import { getOverallErrorTimeseries } from '../lib/correlations/errors/get_overal import { getCorrelationsForSlowTransactions } from '../lib/correlations/latency/get_correlations_for_slow_transactions'; import { getOverallLatencyDistribution } from '../lib/correlations/latency/get_overall_latency_distribution'; import { setupRequest } from '../lib/helpers/setup_request'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; const INVALID_LICENSE = i18n.translate( @@ -25,7 +26,7 @@ const INVALID_LICENSE = i18n.translate( } ); -export const correlationsLatencyDistributionRoute = createRoute({ +const correlationsLatencyDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/overall_distribution', params: t.type({ query: t.intersection([ @@ -40,18 +41,19 @@ export const correlationsLatencyDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallLatencyDistribution({ environment, @@ -64,7 +66,7 @@ export const correlationsLatencyDistributionRoute = createRoute({ }, }); -export const correlationsForSlowTransactionsRoute = createRoute({ +const correlationsForSlowTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/slow_transactions', params: t.type({ query: t.intersection([ @@ -85,11 +87,13 @@ export const correlationsForSlowTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -100,7 +104,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ fieldNames, maxLatency, distributionInterval, - } = context.params.query; + } = params.query; return getCorrelationsForSlowTransactions({ environment, @@ -117,7 +121,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ }, }); -export const correlationsErrorDistributionRoute = createRoute({ +const correlationsErrorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/overall_timeseries', params: t.type({ query: t.intersection([ @@ -132,18 +136,20 @@ export const correlationsErrorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallErrorTimeseries({ environment, @@ -156,7 +162,7 @@ export const correlationsErrorDistributionRoute = createRoute({ }, }); -export const correlationsForFailedTransactionsRoute = createRoute({ +const correlationsForFailedTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/failed_transactions', params: t.type({ query: t.intersection([ @@ -174,11 +180,12 @@ export const correlationsForFailedTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -186,7 +193,7 @@ export const correlationsForFailedTransactionsRoute = createRoute({ transactionType, transactionName, fieldNames, - } = context.params.query; + } = params.query; return getCorrelationsForFailedTransactions({ environment, @@ -199,3 +206,9 @@ export const correlationsForFailedTransactionsRoute = createRoute({ }); }, }); + +export const correlationsRouteRepository = createApmServerRouteRepository() + .add(correlationsLatencyDistributionRoute) + .add(correlationsForSlowTransactionsRoute) + .add(correlationsErrorDistributionRoute) + .add(correlationsForFailedTransactionsRoute); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts deleted file mode 100644 index 9958b8dec0124..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; -import { createApi } from './index'; -import { CoreSetup, Logger } from 'src/core/server'; -import { RouteParamsRT } from '../typings'; -import { BehaviorSubject } from 'rxjs'; -import { APMConfig } from '../..'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; - -const getCoreMock = () => { - const get = jest.fn(); - const post = jest.fn(); - const put = jest.fn(); - const createRouter = jest.fn().mockReturnValue({ - get, - post, - put, - }); - - const mock = {} as CoreSetup; - - return { - mock: { - ...mock, - http: { - ...mock.http, - createRouter, - }, - }, - get, - post, - put, - createRouter, - context: { - measure: () => undefined, - config$: new BehaviorSubject({} as APMConfig), - logger: ({ - error: jest.fn(), - } as unknown) as Logger, - plugins: {}, - }, - }; -}; - -const initApi = (params?: RouteParamsRT) => { - const { mock, context, createRouter, get, post } = getCoreMock(); - const handlerMock = jest.fn(); - createApi() - .add(() => ({ - endpoint: 'GET /foo', - params, - options: { tags: ['access:apm'] }, - handler: handlerMock, - })) - .init(mock, context); - - const routeHandler = get.mock.calls[0][1]; - - const responseMock = { - ok: jest.fn(), - custom: jest.fn(), - }; - - const simulateRequest = (requestMock: any) => { - return routeHandler( - {}, - { - // stub default values - params: {}, - query: {}, - body: null, - ...requestMock, - }, - responseMock - ); - }; - - return { - simulateRequest, - handlerMock, - createRouter, - get, - post, - responseMock, - }; -}; - -describe('createApi', () => { - it('registers a route with the server', () => { - const { mock, context, createRouter, post, get, put } = getCoreMock(); - - createApi() - .add(() => ({ - endpoint: 'GET /foo', - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'POST /bar', - params: t.type({ - body: t.string, - }), - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'PUT /baz', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - })) - .add({ - endpoint: 'GET /qux', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - }) - .init(mock, context); - - expect(createRouter).toHaveBeenCalledTimes(1); - - expect(get).toHaveBeenCalledTimes(2); - expect(post).toHaveBeenCalledTimes(1); - expect(put).toHaveBeenCalledTimes(1); - - expect(get.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/foo', - validate: expect.anything(), - }); - - expect(get.mock.calls[1][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/qux', - validate: expect.anything(), - }); - - expect(post.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/bar', - validate: expect.anything(), - }); - - expect(put.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/baz', - validate: expect.anything(), - }); - }); - - describe('when validating', () => { - describe('_inspect', () => { - it('allows _inspect=true', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 'true' } }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: true } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ - body: { _inspect: [] }, - }); - }); - - it('rejects _inspect=1', async () => { - const { simulateRequest, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 1 } }); - - // responds with error handler - expect(responseMock.ok).not.toHaveBeenCalled(); - expect(responseMock.custom).toHaveBeenCalledWith({ - body: { - attributes: { _inspect: [] }, - message: - 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', - }, - statusCode: 400, - }); - }); - - it('allows omitting _inspect', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: {} }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: false } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ body: {} }); - }); - }); - - it('throws if unknown parameters are provided', async () => { - const { simulateRequest, responseMock } = initApi(); - - await simulateRequest({ - query: { _inspect: true, extra: '' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - body: { foo: 'bar' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates path parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - path: t.type({ - foo: t.string, - }), - }) - ); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(handlerMock).toHaveBeenCalledTimes(1); - - expect(responseMock.ok).toHaveBeenCalledTimes(1); - expect(responseMock.custom).not.toHaveBeenCalled(); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - path: { - foo: 'bar', - }, - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - params: { - bar: 'foo', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - params: { - foo: 9, - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - extra: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates body parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - body: t.string, - }) - ); - - await simulateRequest({ - body: '', - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - body: '', - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - body: null, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - - it('validates query parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - query: t.type({ - bar: t.string, - filterNames: jsonRt.pipe(t.array(t.string)), - }), - }) - ); - - await simulateRequest({ - query: { - bar: '', - _inspect: 'true', - filterNames: JSON.stringify(['hostName', 'agentName']), - }, - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - query: { - bar: '', - _inspect: true, - filterNames: ['hostName', 'agentName'], - }, - }); - - await simulateRequest({ - query: { - bar: '', - foo: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts deleted file mode 100644 index 87bc97d346984..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { merge as mergeLodash, pickBy, isEmpty, isPlainObject } from 'lodash'; -import Boom from '@hapi/boom'; -import { schema } from '@kbn/config-schema'; -import * as t from 'io-ts'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaRequest, RouteRegistrar } from 'src/core/server'; -import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; -import agent from 'elastic-apm-node'; -import { parseMethod } from '../../../common/apm_api/parse_endpoint'; -import { merge } from '../../../common/runtime_types/merge'; -import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; -import { APMConfig } from '../..'; -import { InspectResponse, RouteParamsRT, ServerAPI } from '../typings'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; -import type { ApmPluginRequestHandlerContext } from '../typings'; - -const inspectRt = t.exact( - t.partial({ - query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), - }) -); - -type RouteOrRouteFactoryFn = Parameters['add']>[0]; - -const isNotEmpty = (val: any) => - val !== undefined && val !== null && !(isPlainObject(val) && isEmpty(val)); - -export const inspectableEsQueriesMap = new WeakMap< - KibanaRequest, - InspectResponse ->(); - -export function createApi() { - const routes: RouteOrRouteFactoryFn[] = []; - const api: ServerAPI<{}> = { - _S: {}, - add(route) { - routes.push((route as unknown) as RouteOrRouteFactoryFn); - return this as any; - }, - init(core, { config$, logger, plugins }) { - const router = core.http.createRouter(); - - let config = {} as APMConfig; - - config$.subscribe((val) => { - config = val; - }); - - routes.forEach((routeOrFactoryFn) => { - const route = - typeof routeOrFactoryFn === 'function' - ? routeOrFactoryFn(core) - : routeOrFactoryFn; - - const { params, endpoint, options, handler } = route; - - const [method, path] = endpoint.split(' '); - const typedRouterMethod = parseMethod(method); - - // For all runtime types with props, we create an exact - // version that will strip all keys that are unvalidated. - const anyObject = schema.object({}, { unknowns: 'allow' }); - - (router[typedRouterMethod] as RouteRegistrar< - typeof typedRouterMethod, - ApmPluginRequestHandlerContext - >)( - { - path, - options, - validate: { - // `body` can be null, but `validate` expects non-nullable types - // if any validation is defined. Not having validation currently - // means we don't get the payload. See - // https://github.com/elastic/kibana/issues/50179 - body: schema.nullable(anyObject), - params: anyObject, - query: anyObject, - }, - }, - async (context, request, response) => { - if (agent.isStarted()) { - agent.addLabels({ - plugin: 'apm', - }); - } - - // init debug queries - inspectableEsQueriesMap.set(request, []); - - try { - const validParams = validateParams(request, params); - const data = await handler({ - request, - context: { - ...context, - plugins, - params: validParams, - config, - logger, - }, - }); - - const body = { ...data }; - if (validParams.query._inspect) { - body._inspect = inspectableEsQueriesMap.get(request); - } - - // cleanup - inspectableEsQueriesMap.delete(request); - - return response.ok({ body }); - } catch (error) { - logger.error(error); - const opts = { - statusCode: 500, - body: { - message: error.message, - attributes: { - _inspect: inspectableEsQueriesMap.get(request), - }, - }, - }; - - if (Boom.isBoom(error)) { - opts.statusCode = error.output.statusCode; - } - - if (error instanceof RequestAbortedError) { - opts.statusCode = 499; - opts.body.message = 'Client closed request'; - } - - return response.custom(opts); - } - } - ); - }); - }, - }; - - return api; -} - -function validateParams( - request: KibanaRequest, - params: RouteParamsRT | undefined -) { - const paramsRt = params ? merge([params, inspectRt]) : inspectRt; - const paramMap = pickBy( - { - path: request.params, - body: request.body, - query: { - _inspect: 'false', - // @ts-ignore - ...request.query, - }, - }, - isNotEmpty - ); - - const result = strictKeysRt(paramsRt).decode(paramMap); - - if (isLeft(result)) { - throw Boom.badRequest(PathReporter.report(result)[0]); - } - - // Only return values for parameters that have runtime types, - // but always include query as _inspect is always set even if - // it's not defined in the route. - return mergeLodash( - { query: { _inspect: false } }, - pickBy(result.right, isNotEmpty) - ); -} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts deleted file mode 100644 index 5b74aa4347f14..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - staticIndexPatternRoute, - dynamicIndexPatternRoute, - apmIndexPatternTitleRoute, -} from './index_pattern'; -import { createApi } from './create_api'; -import { environmentsRoute } from './environments'; -import { - errorDistributionRoute, - errorGroupsRoute, - errorsRoute, -} from './errors'; -import { - serviceAgentNameRoute, - serviceTransactionTypesRoute, - servicesRoute, - serviceNodeMetadataRoute, - serviceAnnotationsRoute, - serviceAnnotationsCreateRoute, - serviceErrorGroupsPrimaryStatisticsRoute, - serviceErrorGroupsComparisonStatisticsRoute, - serviceThroughputRoute, - serviceDependenciesRoute, - serviceMetadataDetailsRoute, - serviceMetadataIconsRoute, - serviceInstancesPrimaryStatisticsRoute, - serviceInstancesComparisonStatisticsRoute, - serviceProfilingStatisticsRoute, - serviceProfilingTimelineRoute, -} from './services'; -import { - agentConfigurationRoute, - getSingleAgentConfigurationRoute, - agentConfigurationSearchRoute, - deleteAgentConfigurationRoute, - listAgentConfigurationEnvironmentsRoute, - listAgentConfigurationServicesRoute, - createOrUpdateAgentConfigurationRoute, - agentConfigurationAgentNameRoute, -} from './settings/agent_configuration'; -import { - apmIndexSettingsRoute, - apmIndicesRoute, - saveApmIndicesRoute, -} from './settings/apm_indices'; -import { metricsChartsRoute } from './metrics'; -import { serviceNodesRoute } from './service_nodes'; -import { - tracesRoute, - tracesByIdRoute, - rootTransactionByTraceIdRoute, -} from './traces'; -import { - correlationsLatencyDistributionRoute, - correlationsForSlowTransactionsRoute, - correlationsErrorDistributionRoute, - correlationsForFailedTransactionsRoute, -} from './correlations'; -import { - transactionChartsBreakdownRoute, - transactionChartsDistributionRoute, - transactionChartsErrorRateRoute, - transactionGroupsRoute, - transactionGroupsPrimaryStatisticsRoute, - transactionLatencyChartsRoute, - transactionThroughputChartsRoute, - transactionGroupsComparisonStatisticsRoute, -} from './transactions'; -import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; -import { - createCustomLinkRoute, - updateCustomLinkRoute, - deleteCustomLinkRoute, - listCustomLinksRoute, - customLinkTransactionRoute, -} from './settings/custom_link'; -import { - observabilityOverviewHasDataRoute, - observabilityOverviewRoute, -} from './observability_overview'; -import { - anomalyDetectionJobsRoute, - createAnomalyDetectionJobsRoute, - anomalyDetectionEnvironmentsRoute, -} from './settings/anomaly_detection'; -import { - rumHasDataRoute, - rumClientMetricsRoute, - rumJSErrors, - rumLongTaskMetrics, - rumOverviewLocalFiltersRoute, - rumPageLoadDistBreakdownRoute, - rumPageLoadDistributionRoute, - rumPageViewsTrendRoute, - rumServicesRoute, - rumUrlSearch, - rumVisitorsBreakdownRoute, - rumWebCoreVitals, -} from './rum_client'; -import { - transactionErrorRateChartPreview, - transactionErrorCountChartPreview, - transactionDurationChartPreview, -} from './alerts/chart_preview'; - -const createApmApi = () => { - const api = createApi() - // index pattern - .add(staticIndexPatternRoute) - .add(dynamicIndexPatternRoute) - .add(apmIndexPatternTitleRoute) - - // Environments - .add(environmentsRoute) - - // Errors - .add(errorDistributionRoute) - .add(errorGroupsRoute) - .add(errorsRoute) - - // Services - .add(serviceAgentNameRoute) - .add(serviceTransactionTypesRoute) - .add(servicesRoute) - .add(serviceNodeMetadataRoute) - .add(serviceAnnotationsRoute) - .add(serviceAnnotationsCreateRoute) - .add(serviceErrorGroupsPrimaryStatisticsRoute) - .add(serviceThroughputRoute) - .add(serviceDependenciesRoute) - .add(serviceMetadataDetailsRoute) - .add(serviceMetadataIconsRoute) - .add(serviceInstancesPrimaryStatisticsRoute) - .add(serviceInstancesComparisonStatisticsRoute) - .add(serviceErrorGroupsComparisonStatisticsRoute) - .add(serviceProfilingTimelineRoute) - .add(serviceProfilingStatisticsRoute) - - // Agent configuration - .add(getSingleAgentConfigurationRoute) - .add(agentConfigurationAgentNameRoute) - .add(agentConfigurationRoute) - .add(agentConfigurationSearchRoute) - .add(deleteAgentConfigurationRoute) - .add(listAgentConfigurationEnvironmentsRoute) - .add(listAgentConfigurationServicesRoute) - .add(createOrUpdateAgentConfigurationRoute) - - // Correlations - .add(correlationsLatencyDistributionRoute) - .add(correlationsForSlowTransactionsRoute) - .add(correlationsErrorDistributionRoute) - .add(correlationsForFailedTransactionsRoute) - - // APM indices - .add(apmIndexSettingsRoute) - .add(apmIndicesRoute) - .add(saveApmIndicesRoute) - - // Metrics - .add(metricsChartsRoute) - .add(serviceNodesRoute) - - // Traces - .add(tracesRoute) - .add(tracesByIdRoute) - .add(rootTransactionByTraceIdRoute) - - // Transactions - .add(transactionChartsBreakdownRoute) - .add(transactionChartsDistributionRoute) - .add(transactionChartsErrorRateRoute) - .add(transactionGroupsRoute) - .add(transactionGroupsPrimaryStatisticsRoute) - .add(transactionLatencyChartsRoute) - .add(transactionThroughputChartsRoute) - .add(transactionGroupsComparisonStatisticsRoute) - - // Service map - .add(serviceMapRoute) - .add(serviceMapServiceNodeRoute) - - // Custom links - .add(createCustomLinkRoute) - .add(updateCustomLinkRoute) - .add(deleteCustomLinkRoute) - .add(listCustomLinksRoute) - .add(customLinkTransactionRoute) - - // Observability dashboard - .add(observabilityOverviewHasDataRoute) - .add(observabilityOverviewRoute) - - // Anomaly detection - .add(anomalyDetectionJobsRoute) - .add(createAnomalyDetectionJobsRoute) - .add(anomalyDetectionEnvironmentsRoute) - - // User Experience app api routes - .add(rumOverviewLocalFiltersRoute) - .add(rumPageViewsTrendRoute) - .add(rumPageLoadDistributionRoute) - .add(rumPageLoadDistBreakdownRoute) - .add(rumClientMetricsRoute) - .add(rumServicesRoute) - .add(rumVisitorsBreakdownRoute) - .add(rumWebCoreVitals) - .add(rumJSErrors) - .add(rumUrlSearch) - .add(rumLongTaskMetrics) - .add(rumHasDataRoute) - - // Alerting - .add(transactionErrorCountChartPreview) - .add(transactionDurationChartPreview) - .add(transactionErrorRateChartPreview); - - return api; -}; - -export type APMAPI = ReturnType; - -export { createApmApi }; diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route.ts new file mode 100644 index 0000000000000..86330a87a8c55 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createServerRouteFactory } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export const createApmServerRoute = createServerRouteFactory< + APMRouteHandlerResources, + APMRouteCreateOptions +>(); diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts new file mode 100644 index 0000000000000..b7cbe890c57db --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createServerRouteRepository } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export function createApmServerRouteRepository() { + return createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); +} diff --git a/x-pack/plugins/apm/server/routes/create_route.ts b/x-pack/plugins/apm/server/routes/create_route.ts deleted file mode 100644 index d74aac0992eb4..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_route.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from 'src/core/server'; -import { HandlerReturn, Route, RouteParamsRT } from './typings'; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: Route -): Route; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: (core: CoreSetup) => Route -): (core: CoreSetup) => Route; - -export function createRoute(routeOrFactoryFn: Function | object) { - return routeOrFactoryFn; -} diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts index 4aa7d7e6d412f..e06fbdf7fb6d4 100644 --- a/x-pack/plugins/apm/server/routes/environments.ts +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -9,10 +9,11 @@ import * as t from 'io-ts'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getEnvironments } from '../lib/environments/get_environments'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const environmentsRoute = createRoute({ +const environmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/environments', params: t.type({ query: t.intersection([ @@ -23,9 +24,10 @@ export const environmentsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -39,3 +41,7 @@ export const environmentsRoute = createRoute({ return { environments }; }, }); + +export const environmentsRouteRepository = createApmServerRouteRepository().add( + environmentsRoute +); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index f69d3fc9631d1..d6bb1d4bcbaae 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -6,14 +6,15 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { getErrorDistribution } from '../lib/errors/distribution/get_distribution'; import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const errorsRoute = createRoute({ +const errorsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const errorsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { environment, kuery, sortField, sortDirection } = params.query; @@ -49,7 +50,7 @@ export const errorsRoute = createRoute({ }, }); -export const errorGroupsRoute = createRoute({ +const errorGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/{groupId}', params: t.type({ path: t.type({ @@ -59,10 +60,11 @@ export const errorGroupsRoute = createRoute({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, groupId } = context.params.path; - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); + const { serviceName, groupId } = params.path; + const { environment, kuery } = params.query; return getErrorGroupSample({ environment, @@ -74,7 +76,7 @@ export const errorGroupsRoute = createRoute({ }, }); -export const errorDistributionRoute = createRoute({ +const errorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/distribution', params: t.type({ path: t.type({ @@ -90,9 +92,9 @@ export const errorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { environment, kuery, groupId } = params.query; return getErrorDistribution({ @@ -104,3 +106,8 @@ export const errorDistributionRoute = createRoute({ }); }, }); + +export const errorsRouteRepository = createApmServerRouteRepository() + .add(errorsRoute) + .add(errorGroupsRoute) + .add(errorDistributionRoute); diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts new file mode 100644 index 0000000000000..c151752b4b6e0 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + ServerRouteRepository, + ReturnOf, + EndpointOf, +} from '@kbn/server-route-repository'; +import { PickByValue } from 'utility-types'; +import { alertsChartPreviewRouteRepository } from './alerts/chart_preview'; +import { correlationsRouteRepository } from './correlations'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { environmentsRouteRepository } from './environments'; +import { errorsRouteRepository } from './errors'; +import { indexPatternRouteRepository } from './index_pattern'; +import { metricsRouteRepository } from './metrics'; +import { observabilityOverviewRouteRepository } from './observability_overview'; +import { rumRouteRepository } from './rum_client'; +import { serviceRouteRepository } from './services'; +import { serviceMapRouteRepository } from './service_map'; +import { serviceNodeRouteRepository } from './service_nodes'; +import { agentConfigurationRouteRepository } from './settings/agent_configuration'; +import { anomalyDetectionRouteRepository } from './settings/anomaly_detection'; +import { apmIndicesRouteRepository } from './settings/apm_indices'; +import { customLinkRouteRepository } from './settings/custom_link'; +import { traceRouteRepository } from './traces'; +import { transactionRouteRepository } from './transactions'; +import { APMRouteHandlerResources } from './typings'; + +const getTypedGlobalApmServerRouteRepository = () => { + const repository = createApmServerRouteRepository() + .merge(indexPatternRouteRepository) + .merge(environmentsRouteRepository) + .merge(errorsRouteRepository) + .merge(metricsRouteRepository) + .merge(observabilityOverviewRouteRepository) + .merge(rumRouteRepository) + .merge(serviceMapRouteRepository) + .merge(serviceNodeRouteRepository) + .merge(serviceRouteRepository) + .merge(traceRouteRepository) + .merge(transactionRouteRepository) + .merge(alertsChartPreviewRouteRepository) + .merge(correlationsRouteRepository) + .merge(agentConfigurationRouteRepository) + .merge(anomalyDetectionRouteRepository) + .merge(apmIndicesRouteRepository) + .merge(customLinkRouteRepository); + + return repository; +}; + +const getGlobalApmServerRouteRepository = () => { + return getTypedGlobalApmServerRouteRepository() as ServerRouteRepository; +}; + +export type APMServerRouteRepository = ReturnType< + typeof getTypedGlobalApmServerRouteRepository +>; + +// Ensure no APIs return arrays (or, by proxy, the any type), +// to guarantee compatibility with _inspect. + +type CompositeEndpoint = EndpointOf; + +type EndpointReturnTypes = { + [Endpoint in CompositeEndpoint]: ReturnOf; +}; + +type ArrayLikeReturnTypes = PickByValue; + +type ViolatingEndpoints = keyof ArrayLikeReturnTypes; + +function assertType() {} + +// if any endpoint has an array-like return type, the assertion below will fail +assertType(); + +export { getGlobalApmServerRouteRepository }; diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 3b800c23135ce..aa70cde4f96ae 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -6,49 +6,67 @@ */ import { createStaticIndexPattern } from '../lib/index_pattern/create_static_index_pattern'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; import { getApmIndexPatternTitle } from '../lib/index_pattern/get_apm_index_pattern_title'; import { getDynamicIndexPattern } from '../lib/index_pattern/get_dynamic_index_pattern'; +import { createApmServerRoute } from './create_apm_server_route'; -export const staticIndexPatternRoute = createRoute((core) => ({ +const staticIndexPatternRoute = createApmServerRoute({ endpoint: 'POST /api/apm/index_pattern/static', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { + request, + core, + plugins: { spaces }, + config, + } = resources; + const [setup, savedObjectsClient] = await Promise.all([ - setupRequest(context, request), - getInternalSavedObjectsClient(core), + setupRequest(resources), + core + .start() + .then((coreStart) => coreStart.savedObjects.createInternalRepository()), ]); - const spaceId = context.plugins.spaces?.spacesService.getSpaceId(request); + const spaceId = spaces?.setup.spacesService.getSpaceId(request); const didCreateIndexPattern = await createStaticIndexPattern( setup, - context, + config, savedObjectsClient, spaceId ); return { created: didCreateIndexPattern }; }, -})); +}); -export const dynamicIndexPatternRoute = createRoute({ +const dynamicIndexPatternRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/dynamic', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const dynamicIndexPattern = await getDynamicIndexPattern({ context }); + handler: async ({ context, config, logger }) => { + const dynamicIndexPattern = await getDynamicIndexPattern({ + context, + config, + logger, + }); return { dynamicIndexPattern }; }, }); -export const apmIndexPatternTitleRoute = createRoute({ +const indexPatternTitleRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/title', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async ({ config }) => { return { - indexPatternTitle: getApmIndexPatternTitle(context), + indexPatternTitle: getApmIndexPatternTitle(config), }; }, }); + +export const indexPatternRouteRepository = createApmServerRouteRepository() + .add(staticIndexPatternRoute) + .add(dynamicIndexPatternRoute) + .add(indexPatternTitleRoute); diff --git a/x-pack/plugins/apm/server/routes/metrics.ts b/x-pack/plugins/apm/server/routes/metrics.ts index c7e82e13d07b8..9fa2346eb72fb 100644 --- a/x-pack/plugins/apm/server/routes/metrics.ts +++ b/x-pack/plugins/apm/server/routes/metrics.ts @@ -8,10 +8,11 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; -export const metricsChartsRoute = createRoute({ +const metricsChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metrics/charts', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const metricsChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { agentName, environment, kuery, serviceNodeName } = params.query; return await getMetricsChartDataByAgent({ @@ -45,3 +46,7 @@ export const metricsChartsRoute = createRoute({ }); }, }); + +export const metricsRouteRepository = createApmServerRouteRepository().add( + metricsChartsRoute +); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 1aac2c09d01c5..d459570cf7337 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -10,30 +10,32 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceCount } from '../lib/observability_overview/get_service_count'; import { getTransactionsPerMinute } from '../lib/observability_overview/get_transactions_per_minute'; import { getHasData } from '../lib/observability_overview/has_data'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { withApmSpan } from '../utils/with_apm_span'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; -export const observabilityOverviewHasDataRoute = createRoute({ +const observabilityOverviewHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_data', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const res = await getHasData({ setup }); return { hasData: res }; }, }); -export const observabilityOverviewRoute = createRoute({ +const observabilityOverviewRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview', params: t.type({ query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { bucketSize } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { bucketSize } = resources.params.query; + const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -54,3 +56,7 @@ export const observabilityOverviewRoute = createRoute({ }); }, }); + +export const observabilityOverviewRouteRepository = createApmServerRouteRepository() + .add(observabilityOverviewRoute) + .add(observabilityOverviewHasDataRoute); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.test.ts b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts new file mode 100644 index 0000000000000..82b73d46da5c1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts @@ -0,0 +1,507 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { jsonRt } from '@kbn/io-ts-utils'; +import { createServerRouteRepository } from '@kbn/server-route-repository'; +import { ServerRoute } from '@kbn/server-route-repository/target/typings'; +import * as t from 'io-ts'; +import { CoreSetup, Logger } from 'src/core/server'; +import { APMConfig } from '../..'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from '../typings'; +import { registerRoutes } from './index'; + +type RegisterRouteDependencies = Parameters[0]; + +const getRegisterRouteDependencies = () => { + const get = jest.fn(); + const post = jest.fn(); + const put = jest.fn(); + const createRouter = jest.fn().mockReturnValue({ + get, + post, + put, + }); + + const coreSetup = ({ + http: { + createRouter, + }, + } as unknown) as CoreSetup; + + const logger = ({ + error: jest.fn(), + } as unknown) as Logger; + + return { + mocks: { + get, + post, + put, + createRouter, + coreSetup, + logger, + }, + dependencies: ({ + core: { + setup: coreSetup, + }, + logger, + config: {} as APMConfig, + plugins: {}, + } as unknown) as RegisterRouteDependencies, + }; +}; + +const getRepository = () => + createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); + +const initApi = ( + routes: Array< + ServerRoute< + any, + t.Any, + APMRouteHandlerResources, + any, + APMRouteCreateOptions + > + > +) => { + const { mocks, dependencies } = getRegisterRouteDependencies(); + + let repository = getRepository(); + + routes.forEach((route) => { + repository = repository.add(route); + }); + + registerRoutes({ + ...dependencies, + repository, + }); + + const responseMock = { + ok: jest.fn(), + custom: jest.fn(), + }; + + const simulateRequest = (request: { + method: 'get' | 'post' | 'put'; + pathname: string; + params?: Record; + body?: unknown; + query?: Record; + }) => { + const [, registeredRouteHandler] = + mocks[request.method].mock.calls.find((call) => { + return call[0].path === request.pathname; + }) ?? []; + + const result = registeredRouteHandler( + {}, + { + params: {}, + query: {}, + body: null, + ...request, + }, + responseMock + ); + + return result; + }; + + return { + simulateRequest, + mocks: { + ...mocks, + response: responseMock, + }, + }; +}; + +describe('createApi', () => { + it('registers a route with the server', () => { + const { + mocks: { createRouter, get, post, put }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'POST /bar', + params: t.type({ + body: t.string, + }), + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'PUT /baz', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + { + endpoint: 'GET /qux', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + ]); + + expect(createRouter).toHaveBeenCalledTimes(1); + + expect(get).toHaveBeenCalledTimes(2); + expect(post).toHaveBeenCalledTimes(1); + expect(put).toHaveBeenCalledTimes(1); + + expect(get.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/foo', + validate: expect.anything(), + }); + + expect(get.mock.calls[1][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/qux', + validate: expect.anything(), + }); + + expect(post.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/bar', + validate: expect.anything(), + }); + + expect(put.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/baz', + validate: expect.anything(), + }); + }); + + describe('when validating', () => { + describe('_inspect', () => { + it('allows _inspect=true', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true' }, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: true } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledWith({ + body: { _inspect: [] }, + }); + }); + + it('rejects _inspect=1', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 1 }, + }); + + // responds with error handler + expect(response.ok).not.toHaveBeenCalled(); + expect(response.custom).toHaveBeenCalledWith({ + body: { + attributes: { _inspect: [] }, + message: + 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', + }, + statusCode: 400, + }); + }); + + it('allows omitting _inspect', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: handlerMock }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: {}, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: false } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledWith({ body: {} }); + }); + }); + + it('throws if unknown parameters are provided', async () => { + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: jest.fn() }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true', extra: '' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: { foo: 'bar' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates path parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: [] }, + params: t.type({ + path: t.type({ + foo: t.string, + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledTimes(1); + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + path: { + foo: 'bar', + }, + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + bar: 'foo', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 9, + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + extra: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates body parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + body: t.string, + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: '', + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + body: '', + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: null, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + + it('validates query parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + query: t.type({ + bar: t.string, + filterNames: jsonRt.pipe(t.array(t.string)), + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + _inspect: 'true', + filterNames: JSON.stringify(['hostName', 'agentName']), + }, + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + query: { + bar: '', + _inspect: true, + filterNames: ['hostName', 'agentName'], + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + foo: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.ts b/x-pack/plugins/apm/server/routes/register_routes/index.ts new file mode 100644 index 0000000000000..3a88a496b923f --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import * as t from 'io-ts'; +import { KibanaRequest, RouteRegistrar } from 'src/core/server'; +import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import agent from 'elastic-apm-node'; +import { ServerRouteRepository } from '@kbn/server-route-repository'; +import { merge } from 'lodash'; +import { + decodeRequestParams, + parseEndpoint, + routeValidationObject, +} from '@kbn/server-route-repository'; +import { mergeRt, jsonRt } from '@kbn/io-ts-utils'; +import { pickKeys } from '../../../common/utils/pick_keys'; +import { APMRouteHandlerResources, InspectResponse } from '../typings'; +import type { ApmPluginRequestHandlerContext } from '../typings'; + +const inspectRt = t.exact( + t.partial({ + query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), + }) +); + +export const inspectableEsQueriesMap = new WeakMap< + KibanaRequest, + InspectResponse +>(); + +export function registerRoutes({ + core, + repository, + plugins, + logger, + config, +}: { + core: APMRouteHandlerResources['core']; + plugins: APMRouteHandlerResources['plugins']; + logger: APMRouteHandlerResources['logger']; + repository: ServerRouteRepository; + config: APMRouteHandlerResources['config']; +}) { + const routes = repository.getRoutes(); + + const router = core.setup.http.createRouter(); + + routes.forEach((route) => { + const { params, endpoint, options, handler } = route; + + const { method, pathname } = parseEndpoint(endpoint); + + (router[method] as RouteRegistrar< + typeof method, + ApmPluginRequestHandlerContext + >)( + { + path: pathname, + options, + validate: routeValidationObject, + }, + async (context, request, response) => { + if (agent.isStarted()) { + agent.addLabels({ + plugin: 'apm', + }); + } + + // init debug queries + inspectableEsQueriesMap.set(request, []); + + try { + const runtimeType = params ? mergeRt(params, inspectRt) : inspectRt; + + const validatedParams = decodeRequestParams( + pickKeys(request, 'params', 'body', 'query'), + runtimeType + ); + + const data: Record | undefined | null = (await handler({ + request, + context, + config, + logger, + core, + plugins, + params: merge( + { + query: { + _inspect: false, + }, + }, + validatedParams + ), + })) as any; + + if (Array.isArray(data)) { + throw new Error('Return type cannot be an array'); + } + + const body = validatedParams.query?._inspect + ? { + ...data, + _inspect: inspectableEsQueriesMap.get(request), + } + : { ...data }; + + // cleanup + inspectableEsQueriesMap.delete(request); + + return response.ok({ body }); + } catch (error) { + logger.error(error); + const opts = { + statusCode: 500, + body: { + message: error.message, + attributes: { + _inspect: inspectableEsQueriesMap.get(request), + }, + }, + }; + + if (Boom.isBoom(error)) { + opts.statusCode = error.output.statusCode; + } + + if (error instanceof RequestAbortedError) { + opts.statusCode = 499; + opts.body.message = 'Client closed request'; + } + + return response.custom(opts); + } + } + ); + }); +} diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 3156acb469a72..d7f91adc0d683 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { jsonRt } from '../../common/runtime_types/json_rt'; +import { jsonRt } from '@kbn/io-ts-utils'; import { LocalUIFilterName } from '../../common/ui_filter'; import { Setup, @@ -28,9 +28,10 @@ import { getLocalUIFilters } from '../lib/rum_client/ui_filters/local_ui_filters import { localUIFilterNames } from '../lib/rum_client/ui_filters/local_ui_filters/config'; import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; import { Projection } from '../projections/typings'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { rangeRt } from './default_api_types'; -import { APMRequestHandlerContext } from './typings'; +import { APMRouteHandlerResources } from './typings'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -45,18 +46,18 @@ const uxQueryRt = t.intersection([ t.partial({ urlQuery: t.string, percentile: t.string }), ]); -export const rumClientMetricsRoute = createRoute({ +const rumClientMetricsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum/client-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getClientMetrics({ setup, @@ -66,18 +67,18 @@ export const rumClientMetricsRoute = createRoute({ }, }); -export const rumPageLoadDistributionRoute = createRoute({ +const rumPageLoadDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution', params: t.type({ query: t.intersection([uxQueryRt, percentileRangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistribution = await getPageLoadDistribution({ setup, @@ -90,7 +91,7 @@ export const rumPageLoadDistributionRoute = createRoute({ }, }); -export const rumPageLoadDistBreakdownRoute = createRoute({ +const rumPageLoadDistBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution/breakdown', params: t.type({ query: t.intersection([ @@ -100,12 +101,12 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, breakdown, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistBreakdown = await getPageLoadDistBreakdown({ setup, @@ -119,18 +120,18 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ }, }); -export const rumPageViewsTrendRoute = createRoute({ +const rumPageViewsTrendRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-view-trends', params: t.type({ query: t.intersection([uxQueryRt, t.partial({ breakdowns: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { breakdowns, urlQuery }, - } = context.params; + } = resources.params; return getPageViewTrends({ setup, @@ -140,32 +141,32 @@ export const rumPageViewsTrendRoute = createRoute({ }, }); -export const rumServicesRoute = createRoute({ +const rumServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/services', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const rumServices = await getRumServices({ setup }); return { rumServices }; }, }); -export const rumVisitorsBreakdownRoute = createRoute({ +const rumVisitorsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/visitor-breakdown', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery }, - } = context.params; + } = resources.params; return getVisitorBreakdown({ setup, @@ -174,18 +175,18 @@ export const rumVisitorsBreakdownRoute = createRoute({ }, }); -export const rumWebCoreVitals = createRoute({ +const rumWebCoreVitals = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/web-core-vitals', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getWebCoreVitals({ setup, @@ -195,18 +196,18 @@ export const rumWebCoreVitals = createRoute({ }, }); -export const rumLongTaskMetrics = createRoute({ +const rumLongTaskMetrics = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/long-task-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getLongTaskMetrics({ setup, @@ -216,24 +217,24 @@ export const rumLongTaskMetrics = createRoute({ }, }); -export const rumUrlSearch = createRoute({ +const rumUrlSearch = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/url-search', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getUrlSearch({ setup, urlQuery, percentile: Number(percentile) }); }, }); -export const rumJSErrors = createRoute({ +const rumJSErrors = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/js-errors', params: t.type({ query: t.intersection([ @@ -244,12 +245,12 @@ export const rumJSErrors = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { pageSize, pageIndex, urlQuery }, - } = context.params; + } = resources.params; return getJSErrors({ setup, @@ -260,14 +261,14 @@ export const rumJSErrors = createRoute({ }, }); -export const rumHasDataRoute = createRoute({ +const rumHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); return await hasRumData({ setup }); }, }); @@ -309,21 +310,22 @@ function createLocalFiltersRoute< >; queryRt: TQueryRT; }) { - return createRoute({ + return createApmServerRoute({ endpoint, params: t.type({ query: t.intersection([localUiBaseQueryRt, queryRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { uiFilters } = setup; - const { query } = context.params; + + const { query } = resources.params; const { filterNames } = query; const projection = await getProjection({ query, - context, + resources, setup, }); @@ -339,7 +341,7 @@ function createLocalFiltersRoute< }); } -export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ +const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ endpoint: 'GET /api/apm/rum/local_filters', getProjection: async ({ setup }) => { return getRumPageLoadTransactionsProjection({ @@ -357,9 +359,23 @@ type GetProjection< > = ({ query, setup, - context, + resources, }: { query: t.TypeOf; setup: Setup & SetupTimeRange; - context: APMRequestHandlerContext; + resources: APMRouteHandlerResources; }) => Promise | TProjection; + +export const rumRouteRepository = createApmServerRouteRepository() + .add(rumClientMetricsRoute) + .add(rumPageLoadDistributionRoute) + .add(rumPageLoadDistBreakdownRoute) + .add(rumPageViewsTrendRoute) + .add(rumServicesRoute) + .add(rumVisitorsBreakdownRoute) + .add(rumWebCoreVitals) + .add(rumLongTaskMetrics) + .add(rumUrlSearch) + .add(rumJSErrors) + .add(rumHasDataRoute) + .add(rumOverviewLocalFiltersRoute); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 33943d6e05d01..267479de4c102 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -11,13 +11,14 @@ import { invalidLicenseMessage } from '../../common/service_map'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, rangeRt } from './default_api_types'; import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { isActivePlatinumLicense } from '../../common/license_check'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const serviceMapRoute = createRoute({ +const serviceMapRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map', params: t.type({ query: t.intersection([ @@ -29,8 +30,9 @@ export const serviceMapRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params, logger } = resources; + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { @@ -42,11 +44,10 @@ export const serviceMapRoute = createRoute({ featureName: 'serviceMaps', }); - const logger = context.logger; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { query: { serviceName, environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -61,7 +62,7 @@ export const serviceMapRoute = createRoute({ }, }); -export const serviceMapServiceNodeRoute = createRoute({ +const serviceMapServiceNodeRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map/service/{serviceName}', params: t.type({ path: t.type({ @@ -70,19 +71,21 @@ export const serviceMapServiceNodeRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params } = resources; + + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { path: { serviceName }, query: { environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +99,7 @@ export const serviceMapServiceNodeRoute = createRoute({ }); }, }); + +export const serviceMapRouteRepository = createApmServerRouteRepository() + .add(serviceMapRoute) + .add(serviceMapServiceNodeRoute); diff --git a/x-pack/plugins/apm/server/routes/service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes.ts index e9060688c63a6..a2eb12662cbca 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes.ts @@ -6,12 +6,13 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceNodes } from '../lib/service_nodes'; import { rangeRt, kueryRt } from './default_api_types'; -export const serviceNodesRoute = createRoute({ +const serviceNodesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/serviceNodes', params: t.type({ path: t.type({ @@ -20,9 +21,9 @@ export const serviceNodesRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { kuery } = params.query; @@ -30,3 +31,7 @@ export const serviceNodesRoute = createRoute({ return { serviceNodes }; }, }); + +export const serviceNodeRouteRepository = createApmServerRouteRepository().add( + serviceNodesRoute +); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index b4d25ca8b2a06..800a5bdcc5d5f 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -6,15 +6,12 @@ */ import Boom from '@hapi/boom'; +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { uniq } from 'lodash'; -import { - LatencyAggregationType, - latencyAggregationTypeRt, -} from '../../common/latency_aggregation_types'; +import { latencyAggregationTypeRt } from '../../common/latency_aggregation_types'; import { ProfilingValueType } from '../../common/profiling'; import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -35,7 +32,8 @@ import { getServiceProfilingStatistics } from '../lib/services/profiling/get_ser import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; import { withApmSpan } from '../utils/with_apm_span'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -43,15 +41,16 @@ import { rangeRt, } from './default_api_types'; -export const servicesRoute = createRoute({ +const servicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -61,21 +60,22 @@ export const servicesRoute = createRoute({ kuery, setup, searchAggregatedTransactions, - logger: context.logger, + logger, }); }, }); -export const serviceMetadataDetailsRoute = createRoute({ +const serviceMetadataDetailsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -89,16 +89,17 @@ export const serviceMetadataDetailsRoute = createRoute({ }, }); -export const serviceMetadataIconsRoute = createRoute({ +const serviceMetadataIconsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -112,7 +113,7 @@ export const serviceMetadataIconsRoute = createRoute({ }, }); -export const serviceAgentNameRoute = createRoute({ +const serviceAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/agent_name', params: t.type({ path: t.type({ @@ -121,9 +122,10 @@ export const serviceAgentNameRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -136,7 +138,7 @@ export const serviceAgentNameRoute = createRoute({ }, }); -export const serviceTransactionTypesRoute = createRoute({ +const serviceTransactionTypesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction_types', params: t.type({ path: t.type({ @@ -145,9 +147,11 @@ export const serviceTransactionTypesRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + return getServiceTransactionTypes({ serviceName, setup, @@ -158,7 +162,7 @@ export const serviceTransactionTypesRoute = createRoute({ }, }); -export const serviceNodeMetadataRoute = createRoute({ +const serviceNodeMetadataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', params: t.type({ @@ -169,10 +173,11 @@ export const serviceNodeMetadataRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, serviceNodeName } = context.params.path; - const { kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName, serviceNodeName } = params.path; + const { kuery } = params.query; return getServiceNodeMetadata({ kuery, @@ -183,7 +188,7 @@ export const serviceNodeMetadataRoute = createRoute({ }, }); -export const serviceAnnotationsRoute = createRoute({ +const serviceAnnotationsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', params: t.type({ path: t.type({ @@ -192,12 +197,13 @@ export const serviceAnnotationsRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, plugins, context, request, logger } = resources; + const { serviceName } = params.path; + const { environment } = params.query; - const { observability } = context.plugins; + const { observability } = plugins; const [ annotationsClient, @@ -205,7 +211,7 @@ export const serviceAnnotationsRoute = createRoute({ ] = await Promise.all([ observability ? withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined, getSearchAggregatedTransactions(setup), @@ -218,12 +224,12 @@ export const serviceAnnotationsRoute = createRoute({ serviceName, annotationsClient, client: context.core.elasticsearch.client.asCurrentUser, - logger: context.logger, + logger, }); }, }); -export const serviceAnnotationsCreateRoute = createRoute({ +const serviceAnnotationsCreateRoute = createApmServerRoute({ endpoint: 'POST /api/apm/services/{serviceName}/annotation', options: { tags: ['access:apm', 'access:apm_write'], @@ -250,12 +256,17 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), ]), }), - handler: async ({ request, context }) => { - const { observability } = context.plugins; + handler: async (resources) => { + const { + request, + context, + plugins: { observability }, + params, + } = resources; const annotationsClient = observability ? await withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined; @@ -263,7 +274,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ throw Boom.notFound(); } - const { body, path } = context.params; + const { body, path } = params; return withApmSpan('create_annotation', () => annotationsClient.create({ @@ -283,7 +294,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }, }); -export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ +const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics', params: t.type({ @@ -300,13 +311,14 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { kuery, transactionType, environment }, - } = context.params; + } = params; return getServiceErrorGroupPrimaryStatistics({ kuery, serviceName, @@ -317,7 +329,7 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ +const serviceErrorGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics', params: t.type({ @@ -337,8 +349,9 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, @@ -351,7 +364,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return getServiceErrorGroupPeriods({ environment, @@ -367,7 +380,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ }, }); -export const serviceThroughputRoute = createRoute({ +const serviceThroughputRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/throughput', params: t.type({ path: t.type({ @@ -382,16 +395,17 @@ export const serviceThroughputRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, transactionType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -432,7 +446,7 @@ export const serviceThroughputRoute = createRoute({ }, }); -export const serviceInstancesPrimaryStatisticsRoute = createRoute({ +const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', params: t.type({ @@ -450,12 +464,16 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -479,7 +497,7 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceInstancesComparisonStatisticsRoute = createRoute({ +const serviceInstancesComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', params: t.type({ @@ -500,9 +518,10 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -511,9 +530,8 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ comparisonEnd, serviceNodeIds, numBuckets, - } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -535,7 +553,7 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ }, }); -export const serviceDependenciesRoute = createRoute({ +const serviceDependenciesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/dependencies', params: t.type({ path: t.type({ @@ -552,11 +570,11 @@ export const serviceDependenciesRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - - const { serviceName } = context.params.path; - const { environment, numBuckets } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, numBuckets } = params.query; const serviceDependencies = await getServiceDependencies({ serviceName, @@ -569,7 +587,7 @@ export const serviceDependenciesRoute = createRoute({ }, }); -export const serviceProfilingTimelineRoute = createRoute({ +const serviceProfilingTimelineRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', params: t.type({ path: t.type({ @@ -580,13 +598,13 @@ export const serviceProfilingTimelineRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { environment, kuery }, - } = context.params; + } = params; const profilingTimeline = await getServiceProfilingTimeline({ kuery, @@ -599,7 +617,7 @@ export const serviceProfilingTimelineRoute = createRoute({ }, }); -export const serviceProfilingStatisticsRoute = createRoute({ +const serviceProfilingStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', params: t.type({ path: t.type({ @@ -625,13 +643,15 @@ export const serviceProfilingStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params, logger } = resources; const { path: { serviceName }, query: { environment, kuery, valueType }, - } = context.params; + } = params; return getServiceProfilingStatistics({ kuery, @@ -639,7 +659,25 @@ export const serviceProfilingStatisticsRoute = createRoute({ environment, valueType, setup, - logger: context.logger, + logger, }); }, }); + +export const serviceRouteRepository = createApmServerRouteRepository() + .add(servicesRoute) + .add(serviceMetadataDetailsRoute) + .add(serviceMetadataIconsRoute) + .add(serviceAgentNameRoute) + .add(serviceTransactionTypesRoute) + .add(serviceNodeMetadataRoute) + .add(serviceAnnotationsRoute) + .add(serviceAnnotationsCreateRoute) + .add(serviceErrorGroupsPrimaryStatisticsRoute) + .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceThroughputRoute) + .add(serviceInstancesPrimaryStatisticsRoute) + .add(serviceInstancesComparisonStatisticsRoute) + .add(serviceDependenciesRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 31e8d6cc1e9f0..111e0a18c8608 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -16,7 +16,7 @@ import { findExactConfiguration } from '../../lib/settings/agent_configuration/f import { listConfigurations } from '../../lib/settings/agent_configuration/list_configurations'; import { getEnvironments } from '../../lib/settings/agent_configuration/get_environments'; import { deleteConfiguration } from '../../lib/settings/agent_configuration/delete_configuration'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAgentNameByService } from '../../lib/settings/agent_configuration/get_agent_name_by_service'; import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_applied_by_agent'; import { @@ -24,34 +24,37 @@ import { agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get list of configurations -export const agentConfigurationRoute = createRoute({ +const agentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const configurations = await listConfigurations({ setup }); return { configurations }; }, }); // get a single configuration -export const getSingleAgentConfigurationRoute = createRoute({ +const getSingleAgentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/view', params: t.partial({ query: serviceRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { name, environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { name, environment } = params.query; const service = { name, environment }; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); @@ -63,7 +66,7 @@ export const getSingleAgentConfigurationRoute = createRoute({ }); // delete configuration -export const deleteAgentConfigurationRoute = createRoute({ +const deleteAgentConfigurationRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -73,20 +76,22 @@ export const deleteAgentConfigurationRoute = createRoute({ service: serviceRt, }), }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { service } = context.params.body; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { service } = params.body; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( + logger.info( `Deleting config ${service.name}/${service.environment} (${config._id})` ); @@ -98,7 +103,7 @@ export const deleteAgentConfigurationRoute = createRoute({ }); // create/update configuration -export const createOrUpdateAgentConfigurationRoute = createRoute({ +const createOrUpdateAgentConfigurationRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -107,9 +112,10 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { body, query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { body, query } = params; // if the config already exists, it is fetched and updated // this is to avoid creating two configs with identical service params @@ -125,13 +131,13 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ ); } - context.logger.info( + logger.info( `${config ? 'Updating' : 'Creating'} config ${body.service.name}/${ body.service.environment }` ); - return await createOrUpdateConfiguration({ + await createOrUpdateConfiguration({ configurationId: config?._id, configurationIntake: body, setup, @@ -147,35 +153,35 @@ const searchParamsRt = t.intersection([ export type AgentConfigSearchParams = t.TypeOf; // Lookup single configuration (used by APM Server) -export const agentConfigurationSearchRoute = createRoute({ +const agentConfigurationSearchRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/agent-configuration/search', params: t.type({ body: searchParamsRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, logger } = resources; + const { service, etag, mark_as_applied_by_agent: markAsAppliedByAgent, - } = context.params.body; + } = params.body; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const config = await searchConfigurations({ service, setup, }); if (!config) { - context.logger.debug( + logger.debug( `[Central configuration] Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( - `Config was found for ${service.name}/${service.environment}` - ); + logger.info(`Config was found for ${service.name}/${service.environment}`); // update `applied_by_agent` field // when `markAsAppliedByAgent` is true (Jaeger agent doesn't have etags) @@ -197,11 +203,11 @@ export const agentConfigurationSearchRoute = createRoute({ */ // get list of services -export const listAgentConfigurationServicesRoute = createRoute({ +const listAgentConfigurationServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/services', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -215,15 +221,17 @@ export const listAgentConfigurationServicesRoute = createRoute({ }); // get environments for service -export const listAgentConfigurationEnvironmentsRoute = createRoute({ +const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/environments', params: t.partial({ query: t.partial({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -239,16 +247,27 @@ export const listAgentConfigurationEnvironmentsRoute = createRoute({ }); // get agentName for service -export const agentConfigurationAgentNameRoute = createRoute({ +const agentConfigurationAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/agent_name', params: t.type({ query: t.type({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const agentName = await getAgentNameByService({ serviceName, setup }); return { agentName }; }, }); + +export const agentConfigurationRouteRepository = createApmServerRouteRepository() + .add(agentConfigurationRoute) + .add(getSingleAgentConfigurationRoute) + .add(deleteAgentConfigurationRoute) + .add(createOrUpdateAgentConfigurationRoute) + .add(agentConfigurationSearchRoute) + .add(listAgentConfigurationServicesRoute) + .add(listAgentConfigurationEnvironmentsRoute) + .add(agentConfigurationAgentNameRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index de7f35c4081bc..98467e1a4a0dd 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { ML_ERRORS } from '../../../common/anomaly_detection'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../lib/helpers/setup_request'; @@ -18,15 +18,17 @@ import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; import { notifyFeatureUsage } from '../../feature'; import { withApmSpan } from '../../utils/with_apm_span'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get ML anomaly detection jobs for each environment -export const anomalyDetectionJobsRoute = createRoute({ +const anomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:ml:canGetJobs'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { context, logger } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); @@ -34,7 +36,7 @@ export const anomalyDetectionJobsRoute = createRoute({ const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), + getAnomalyDetectionJobs(setup, logger), hasLegacyJobs(setup), ]) ); @@ -47,7 +49,7 @@ export const anomalyDetectionJobsRoute = createRoute({ }); // create new ML anomaly detection jobs for each given environment -export const createAnomalyDetectionJobsRoute = createRoute({ +const createAnomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:apm_write', 'access:ml:canCreateJob'], @@ -57,15 +59,17 @@ export const createAnomalyDetectionJobsRoute = createRoute({ environments: t.array(t.string), }), }), - handler: async ({ context, request }) => { - const { environments } = context.params.body; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params, context, logger } = resources; + const { environments } = params.body; + + const setup = await setupRequest(resources); if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - await createAnomalyDetectionJobs(setup, environments, context.logger); + await createAnomalyDetectionJobs(setup, environments, logger); notifyFeatureUsage({ licensingPlugin: context.licensing, @@ -77,11 +81,11 @@ export const createAnomalyDetectionJobsRoute = createRoute({ }); // get all available environments to create anomaly detection jobs for -export const anomalyDetectionEnvironmentsRoute = createRoute({ +const anomalyDetectionEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/environments', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +100,8 @@ export const anomalyDetectionEnvironmentsRoute = createRoute({ return { environments }; }, }); + +export const anomalyDetectionRouteRepository = createApmServerRouteRepository() + .add(anomalyDetectionJobsRoute) + .add(createAnomalyDetectionJobsRoute) + .add(anomalyDetectionEnvironmentsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts index 91057c97579e4..003471aa89f39 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts @@ -6,7 +6,8 @@ */ import * as t from 'io-ts'; -import { createRoute } from '../create_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getApmIndices, getApmIndexSettings, @@ -14,29 +15,30 @@ import { import { saveApmIndices } from '../../lib/settings/apm_indices/save_apm_indices'; // get list of apm indices and values -export const apmIndexSettingsRoute = createRoute({ +const apmIndexSettingsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-index-settings', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const apmIndexSettings = await getApmIndexSettings({ context }); + handler: async ({ config, context }) => { + const apmIndexSettings = await getApmIndexSettings({ config, context }); return { apmIndexSettings }; }, }); // get apm indices configuration object -export const apmIndicesRoute = createRoute({ +const apmIndicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-indices', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async (resources) => { + const { context, config } = resources; return await getApmIndices({ savedObjectsClient: context.core.savedObjects.client, - config: context.config, + config, }); }, }); // save ui indices -export const saveApmIndicesRoute = createRoute({ +const saveApmIndicesRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/apm-indices/save', options: { tags: ['access:apm', 'access:apm_write'], @@ -53,9 +55,15 @@ export const saveApmIndicesRoute = createRoute({ /* eslint-enable @typescript-eslint/naming-convention */ }), }), - handler: async ({ context }) => { - const { body } = context.params; + handler: async (resources) => { + const { params, context } = resources; + const { body } = params; const savedObjectsClient = context.core.savedObjects.client; return await saveApmIndices(savedObjectsClient, body); }, }); + +export const apmIndicesRouteRepository = createApmServerRouteRepository() + .add(apmIndexSettingsRoute) + .add(apmIndicesRoute) + .add(saveApmIndicesRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index a6ab553f09419..c9c5d236c14f9 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -21,35 +21,40 @@ import { import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; -export const customLinkTransactionRoute = createRoute({ +const customLinkTransactionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links/transaction', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { query } = params; // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); return await getTransaction({ setup, filters }); }, }); -export const listCustomLinksRoute = createRoute({ +const listCustomLinksRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { query } = context.params; + const setup = await setupRequest(resources); + + const { query } = params; + // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); const customLinks = await listCustomLinks({ setup, filters }); @@ -57,29 +62,30 @@ export const listCustomLinksRoute = createRoute({ }, }); -export const createCustomLinkRoute = createRoute({ +const createCustomLinkRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/custom_links', params: t.type({ body: payloadRt, }), options: { tags: ['access:apm', 'access:apm_write'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ customLink, setup }); + const setup = await setupRequest(resources); + const customLink = params.body; notifyFeatureUsage({ licensingPlugin: context.licensing, featureName: 'customLinks', }); - return res; + + await createOrUpdateCustomLink({ customLink, setup }); }, }); -export const updateCustomLinkRoute = createRoute({ +const updateCustomLinkRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -90,23 +96,26 @@ export const updateCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ + const setup = await setupRequest(resources); + + const { id } = params.path; + const customLink = params.body; + + await createOrUpdateCustomLink({ customLinkId: id, customLink, setup, }); - return res; }, }); -export const deleteCustomLinkRoute = createRoute({ +const deleteCustomLinkRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -116,12 +125,14 @@ export const deleteCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; + const setup = await setupRequest(resources); + const { id } = params.path; const res = await deleteCustomLink({ customLinkId: id, setup, @@ -129,3 +140,10 @@ export const deleteCustomLinkRoute = createRoute({ return res; }, }); + +export const customLinkRouteRepository = createApmServerRouteRepository() + .add(customLinkTransactionRoute) + .add(listCustomLinksRoute) + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute); diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 6287ffbf0c751..dd392982b02fd 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -9,20 +9,22 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTrace } from '../lib/traces/get_trace'; import { getTransactionGroupList } from '../lib/transaction_groups'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getRootTransactionByTraceId } from '../lib/transactions/get_transaction_by_trace'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const tracesRoute = createRoute({ +const tracesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -34,7 +36,7 @@ export const tracesRoute = createRoute({ }, }); -export const tracesByIdRoute = createRoute({ +const tracesByIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}', params: t.type({ path: t.type({ @@ -43,13 +45,16 @@ export const tracesByIdRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - return getTrace(context.params.path.traceId, setup); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { traceId } = params.path; + return getTrace(traceId, setup); }, }); -export const rootTransactionByTraceIdRoute = createRoute({ +const rootTransactionByTraceIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}/root_transaction', params: t.type({ path: t.type({ @@ -57,9 +62,15 @@ export const rootTransactionByTraceIdRoute = createRoute({ }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const { traceId } = context.params.path; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const { traceId } = params.path; + const setup = await setupRequest(resources); return getRootTransactionByTraceId(traceId, setup); }, }); + +export const traceRouteRepository = createApmServerRouteRepository() + .add(tracesByIdRoute) + .add(tracesRoute) + .add(rootTransactionByTraceIdRoute); diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index f3424a252e409..ebca374db86d7 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -5,12 +5,12 @@ * 2.0. */ +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { LatencyAggregationType, latencyAggregationTypeRt, } from '../../common/latency_aggregation_types'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -23,7 +23,8 @@ import { getLatencyPeriods } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; import { getTransactionGroupList } from '../lib/transaction_groups'; import { getErrorRatePeriods } from '../lib/transaction_groups/get_error_rate'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -35,7 +36,7 @@ import { * Returns a list of transactions grouped by name * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ */ -export const transactionGroupsRoute = createRoute({ +const transactionGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', params: t.type({ path: t.type({ @@ -49,10 +50,11 @@ export const transactionGroupsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, kuery, transactionType } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -72,7 +74,7 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsPrimaryStatisticsRoute = createRoute({ +const transactionGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', params: t.type({ @@ -90,8 +92,9 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -100,7 +103,7 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, - } = context.params; + } = params; return getServiceTransactionGroups({ environment, @@ -109,12 +112,12 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ serviceName, searchAggregatedTransactions, transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }); }, }); -export const transactionGroupsComparisonStatisticsRoute = createRoute({ +const transactionGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', params: t.type({ @@ -135,13 +138,15 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); + const { params } = resources; + const { path: { serviceName }, query: { @@ -154,7 +159,7 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return await getServiceTransactionGroupComparisonStatisticsPeriods({ environment, @@ -165,14 +170,14 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ searchAggregatedTransactions, transactionType, numBuckets, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, comparisonStart, comparisonEnd, }); }, }); -export const transactionLatencyChartsRoute = createRoute({ +const transactionLatencyChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', params: t.type({ path: t.type({ @@ -188,10 +193,11 @@ export const transactionLatencyChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const logger = context.logger; - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { serviceName } = params.path; const { environment, kuery, @@ -200,7 +206,7 @@ export const transactionLatencyChartsRoute = createRoute({ latencyAggregationType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -242,7 +248,7 @@ export const transactionLatencyChartsRoute = createRoute({ }, }); -export const transactionThroughputChartsRoute = createRoute({ +const transactionThroughputChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/throughput', params: t.type({ @@ -258,15 +264,17 @@ export const transactionThroughputChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionType, transactionName, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -284,7 +292,7 @@ export const transactionThroughputChartsRoute = createRoute({ }, }); -export const transactionChartsDistributionRoute = createRoute({ +const transactionChartsDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', params: t.type({ @@ -306,9 +314,10 @@ export const transactionChartsDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -316,7 +325,7 @@ export const transactionChartsDistributionRoute = createRoute({ transactionName, transactionId = '', traceId = '', - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -336,7 +345,7 @@ export const transactionChartsDistributionRoute = createRoute({ }, }); -export const transactionChartsBreakdownRoute = createRoute({ +const transactionChartsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown', params: t.type({ path: t.type({ @@ -351,15 +360,17 @@ export const transactionChartsBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionName, transactionType, - } = context.params.query; + } = params.query; return getTransactionBreakdown({ environment, @@ -372,7 +383,7 @@ export const transactionChartsBreakdownRoute = createRoute({ }, }); -export const transactionChartsErrorRateRoute = createRoute({ +const transactionChartsErrorRateRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', params: t.type({ @@ -386,9 +397,10 @@ export const transactionChartsErrorRateRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; const { serviceName } = params.path; const { environment, @@ -416,3 +428,13 @@ export const transactionChartsErrorRateRoute = createRoute({ }); }, }); + +export const transactionRouteRepository = createApmServerRouteRepository() + .add(transactionGroupsRoute) + .add(transactionGroupsPrimaryStatisticsRoute) + .add(transactionGroupsComparisonStatisticsRoute) + .add(transactionLatencyChartsRoute) + .add(transactionThroughputChartsRoute) + .add(transactionChartsDistributionRoute) + .add(transactionChartsBreakdownRoute) + .add(transactionChartsErrorRateRoute); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 3ba24b4ed5268..0fec88a4326c3 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -5,27 +5,19 @@ * 2.0. */ -import t, { Encode, Encoder } from 'io-ts'; import { CoreSetup, - KibanaRequest, RequestHandlerContext, Logger, + KibanaRequest, + CoreStart, } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { RequiredKeys, DeepPartial } from 'utility-types'; -import { SpacesPluginStart } from '../../../spaces/server'; -import { ObservabilityPluginSetup } from '../../../observability/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; -import { SecurityPluginSetup } from '../../../security/server'; -import { MlPluginSetup } from '../../../ml/server'; -import { FetchOptions } from '../../common/fetch_options'; import { APMConfig } from '..'; +import { APMPluginDependencies } from '../types'; -export type HandlerReturn = Record; - -interface InspectQueryParam { - query: { _inspect: boolean }; +export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { + licensing: LicensingApiRequestHandlerContext; } export type InspectResponse = Array<{ @@ -36,141 +28,53 @@ export type InspectResponse = Array<{ esError: Error; }>; -export interface RouteParams { - path?: Record; - query?: Record; - body?: any; +export interface APMRouteCreateOptions { + options: { + tags: Array< + | 'access:apm' + | 'access:apm_write' + | 'access:ml:canGetJobs' + | 'access:ml:canCreateJob' + >; + }; } -type WithoutIncompatibleMethods = Omit< - T, - 'encode' | 'asEncoder' -> & { encode: Encode; asEncoder: () => Encoder }; - -export type RouteParamsRT = WithoutIncompatibleMethods>; - -export type RouteHandler< - TParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> = (kibanaContext: { - context: APMRequestHandlerContext< - (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & - InspectQueryParam - >; +export interface APMRouteHandlerResources { request: KibanaRequest; -}) => Promise; - -interface RouteOptions { - tags: Array< - | 'access:apm' - | 'access:apm_write' - | 'access:ml:canGetJobs' - | 'access:ml:canCreateJob' - >; -} - -export interface Route< - TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> { - endpoint: TEndpoint; - options: RouteOptions; - params?: TRouteParamsRT; - handler: RouteHandler; -} - -/** - * @internal - */ -export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { - licensing: LicensingApiRequestHandlerContext; -} - -export type APMRequestHandlerContext< - TRouteParams = {} -> = ApmPluginRequestHandlerContext & { - params: TRouteParams & InspectQueryParam; + context: ApmPluginRequestHandlerContext; + params: { + query: { + _inspect: boolean; + }; + }; config: APMConfig; logger: Logger; - plugins: { - spaces?: SpacesPluginStart; - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; + core: { + setup: CoreSetup; + start: () => Promise; }; -}; - -export interface RouteState { - [endpoint: string]: { - params?: RouteParams; - ret: any; + plugins: { + [key in keyof APMPluginDependencies]: { + setup: Required[key]['setup']; + start: () => Promise[key]['start']>; + }; }; } -export interface ServerAPI { - _S: TRouteState; - add< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined - >( - route: - | Route - | ((core: CoreSetup) => Route) - ): ServerAPI< - TRouteState & - { - [key in TEndpoint]: { - params: TRouteParamsRT; - ret: TReturn & { _inspect?: InspectResponse }; - }; - } - >; - init: ( - core: CoreSetup, - context: { - config$: Observable; - logger: Logger; - plugins: { - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - }; - } - ) => void; -} - -type MaybeOptional }> = RequiredKeys< - T['params'] -> extends never - ? { params?: T['params'] } - : { params: T['params'] }; - -export type MaybeParams< - TRouteState, - TEndpoint extends keyof TRouteState & string -> = TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ - params: t.OutputOf & - DeepPartial; - }> - : {}; - -export type Client< - TRouteState, - TOptions extends { abortable: boolean } = { abortable: true } -> = ( - options: Omit< - FetchOptions, - 'query' | 'body' | 'pathname' | 'method' | 'signal' - > & { - forceCache?: boolean; - endpoint: TEndpoint; - } & MaybeParams & - (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) -) => Promise< - TRouteState[TEndpoint] extends { ret: any } - ? TRouteState[TEndpoint]['ret'] - : unknown ->; +// export type Client< +// TRouteState, +// TOptions extends { abortable: boolean } = { abortable: true } +// > = ( +// options: Omit< +// FetchOptions, +// 'query' | 'body' | 'pathname' | 'method' | 'signal' +// > & { +// forceCache?: boolean; +// endpoint: TEndpoint; +// } & MaybeParams & +// (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) +// ) => Promise< +// TRouteState[TEndpoint] extends { ret: any } +// ? TRouteState[TEndpoint]['ret'] +// : unknown +// >; diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts new file mode 100644 index 0000000000000..cef9eaf2f4fc0 --- /dev/null +++ b/x-pack/plugins/apm/server/types.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ValuesType } from 'utility-types'; +import { Observable } from 'rxjs'; +import { CoreSetup, CoreStart, KibanaRequest } from 'kibana/server'; +import { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '../../../../src/plugins/data/server'; +import { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; +import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { + HomeServerPluginSetup, + HomeServerPluginStart, +} from '../../../../src/plugins/home/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ActionsPlugin } from '../../actions/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { CloudSetup } from '../../cloud/server'; +import { + PluginSetupContract as FeaturesPluginSetup, + PluginStartContract as FeaturesPluginStart, +} from '../../features/server'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../licensing/server'; +import { MlPluginSetup, MlPluginStart } from '../../ml/server'; +import { ObservabilityPluginSetup } from '../../observability/server'; +import { + SecurityPluginSetup, + SecurityPluginStart, +} from '../../security/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../task_manager/server'; +import { APMConfig } from '.'; +import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; +import { ApmPluginRequestHandlerContext } from './routes/typings'; + +export interface APMPluginSetup { + config$: Observable; + getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: ApmPluginRequestHandlerContext; + }) => Promise>; +} + +interface DependencyMap { + core: { + setup: CoreSetup; + start: CoreStart; + }; + spaces: { + setup: SpacesPluginSetup; + start: SpacesPluginStart; + }; + apmOss: { + setup: APMOSSPluginSetup; + start: undefined; + }; + home: { + setup: HomeServerPluginSetup; + start: HomeServerPluginStart; + }; + licensing: { + setup: LicensingPluginSetup; + start: LicensingPluginStart; + }; + cloud: { + setup: CloudSetup; + start: undefined; + }; + usageCollection: { + setup: UsageCollectionSetup; + start: undefined; + }; + taskManager: { + setup: TaskManagerSetupContract; + start: TaskManagerStartContract; + }; + alerting: { + setup: AlertingPlugin['setup']; + start: AlertingPlugin['start']; + }; + actions: { + setup: ActionsPlugin['setup']; + start: ActionsPlugin['start']; + }; + observability: { + setup: ObservabilityPluginSetup; + start: undefined; + }; + features: { + setup: FeaturesPluginSetup; + start: FeaturesPluginStart; + }; + security: { + setup: SecurityPluginSetup; + start: SecurityPluginStart; + }; + ml: { + setup: MlPluginSetup; + start: MlPluginStart; + }; + data: { + setup: DataPluginSetup; + start: DataPluginStart; + }; +} + +const requiredDependencies = [ + 'features', + 'apmOss', + 'data', + 'licensing', + 'triggersActionsUi', + 'embeddable', + 'infra', +] as const; + +const optionalDependencies = [ + 'spaces', + 'cloud', + 'usageCollection', + 'taskManager', + 'actions', + 'alerting', + 'observability', + 'security', + 'ml', + 'home', + 'maps', +] as const; + +type RequiredDependencies = Pick< + DependencyMap, + ValuesType & keyof DependencyMap +>; + +type OptionalDependencies = Partial< + Pick< + DependencyMap, + ValuesType & keyof DependencyMap + > +>; + +export type APMPluginDependencies = RequiredDependencies & OptionalDependencies; + +export type APMPluginSetupDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['setup']; +}; + +export type APMPluginStartDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['start']; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts index 846e20b48ddca..aa176fe3b188f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts @@ -481,21 +481,84 @@ describe(' serialization', () => { }); }); - test('delete phase', async () => { - const { actions } = testBed; - await actions.delete.enable(true); - await actions.setWaitForSnapshotPolicy('test'); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(entirePolicy.phases.delete).toEqual({ - min_age: '365d', - actions: { - delete: {}, - wait_for_snapshot: { - policy: 'test', + describe('frozen phase', () => { + test('default value', async () => { + const { actions } = testBed; + await actions.frozen.enable(true); + await actions.frozen.setSearchableSnapshot('myRepo'); + + await actions.savePolicy(); + + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.frozen).toEqual({ + min_age: '0d', + actions: { + searchable_snapshot: { snapshot_repository: 'myRepo' }, }, - }, + }); + }); + + describe('deserialization', () => { + beforeEach(async () => { + const policyToEdit = getDefaultHotPhasePolicy('my_policy'); + policyToEdit.policy.phases.frozen = { + min_age: '1234m', + actions: { searchable_snapshot: { snapshot_repository: 'myRepo' } }, + }; + + httpRequestsMockHelpers.setLoadPolicies([policyToEdit]); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + httpRequestsMockHelpers.setListNodes({ + nodesByRoles: {}, + nodesByAttributes: { test: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + test('default value', async () => { + const { actions } = testBed; + + await actions.savePolicy(); + + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.frozen).toEqual({ + min_age: '1234m', + actions: { + searchable_snapshot: { + snapshot_repository: 'myRepo', + }, + }, + }); + }); + }); + }); + + describe('delete phase', () => { + test('default value', async () => { + const { actions } = testBed; + await actions.delete.enable(true); + await actions.setWaitForSnapshotPolicy('test'); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.delete).toEqual({ + min_age: '365d', + actions: { + delete: {}, + wait_for_snapshot: { + policy: 'test', + }, + }, + }); }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index 73ecb0d73b7a7..af571d16ca8c5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -114,6 +114,14 @@ export const createDeserializer = (isCloudEnabled: boolean) => ( } } + if (draft.phases.frozen) { + if (draft.phases.frozen.min_age) { + const minAge = splitSizeAndUnits(draft.phases.frozen.min_age); + draft.phases.frozen.min_age = minAge.size; + draft._meta.frozen.minAgeUnit = minAge.units; + } + } + if (draft.phases.delete) { if (draft.phases.delete.min_age) { const minAge = splitSizeAndUnits(draft.phases.delete.min_age); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index 24dafa6cca237..0b1db784469a9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -267,6 +267,13 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( draft.phases.frozen!.actions = draft.phases.frozen?.actions ?? {}; const frozenPhase = draft.phases.frozen!; + /** + * FROZEN PHASE MIN AGE + */ + if (updatedPolicy.phases.frozen?.min_age) { + frozenPhase.min_age = `${updatedPolicy.phases.frozen!.min_age}${_meta.frozen.minAgeUnit}`; + } + /** * FROZEN PHASE SEARCHABLE SNAPSHOT */ diff --git a/x-pack/plugins/infra/server/kibana.index.ts b/x-pack/plugins/infra/server/kibana.index.ts deleted file mode 100644 index 35d2f845ac4c1..0000000000000 --- a/x-pack/plugins/infra/server/kibana.index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Server } from '@hapi/hapi'; -import JoiNamespace from 'joi'; - -export interface KbnServer extends Server { - usage: any; -} - -// NP_TODO: this is only used in the root index file AFAICT, can remove after migrating to NP -export const getConfigSchema = (Joi: typeof JoiNamespace) => { - const InfraDefaultSourceConfigSchema = Joi.object({ - metricAlias: Joi.string(), - logAlias: Joi.string(), - fields: Joi.object({ - container: Joi.string(), - host: Joi.string(), - message: Joi.array().items(Joi.string()).single(), - pod: Joi.string(), - tiebreaker: Joi.string(), - timestamp: Joi.string(), - }), - }); - - // NP_TODO: make sure this is all represented in the NP config schema - const InfraRootConfigSchema = Joi.object({ - enabled: Joi.boolean().default(true), - query: Joi.object({ - partitionSize: Joi.number(), - partitionFactor: Joi.number(), - }).default(), - sources: Joi.object() - .keys({ - default: InfraDefaultSourceConfigSchema, - }) - .default(), - }).default(); - - return InfraRootConfigSchema; -}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 5244b8a81e75f..8cee4ea588722 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -33,15 +33,25 @@ type ConditionResult = InventoryMetricConditions & { isError: boolean; }; -export const evaluateCondition = async ( - condition: InventoryMetricConditions, - nodeType: InventoryItemType, - source: InfraSource, - logQueryFields: LogQueryFields, - esClient: ElasticsearchClient, - filterQuery?: string, - lookbackSize?: number -): Promise> => { +export const evaluateCondition = async ({ + condition, + nodeType, + source, + logQueryFields, + esClient, + compositeSize, + filterQuery, + lookbackSize, +}: { + condition: InventoryMetricConditions; + nodeType: InventoryItemType; + source: InfraSource; + logQueryFields: LogQueryFields; + esClient: ElasticsearchClient; + compositeSize: number; + filterQuery?: string; + lookbackSize?: number; +}): Promise> => { const { comparator, warningComparator, metric, customMetric } = condition; let { threshold, warningThreshold } = condition; @@ -61,6 +71,7 @@ export const evaluateCondition = async ( timerange, source, logQueryFields, + compositeSize, filterQuery, customMetric ); @@ -105,6 +116,7 @@ const getData = async ( timerange: InfraTimerangeInput, source: InfraSource, logQueryFields: LogQueryFields, + compositeSize: number, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { @@ -128,7 +140,13 @@ const getData = async ( includeTimeseries: Boolean(timerange.lookbackSize), }; try { - const { nodes } = await getNodes(client, snapshotRequest, source, logQueryFields); + const { nodes } = await getNodes( + client, + snapshotRequest, + source, + logQueryFields, + compositeSize + ); if (!nodes.length) return { [UNGROUPED_FACTORY_KEY]: null }; // No Data state diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index d775a503d1d32..8fb8ee54d22ab 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -73,16 +73,19 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = services.savedObjectsClient ); + const compositeSize = libs.configuration.inventory.compositeSize; + const results = await Promise.all( - criteria.map((c) => - evaluateCondition( - c, + criteria.map((condition) => + evaluateCondition({ + condition, nodeType, source, logQueryFields, - services.scopedClusterClient.asCurrentUser, - filterQuery - ) + esClient: services.scopedClusterClient.asCurrentUser, + compositeSize, + filterQuery, + }) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index f254f1e68ae46..00d01b15750d1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -32,6 +32,7 @@ interface PreviewInventoryMetricThresholdAlertParams { params: InventoryMetricThresholdParams; source: InfraSource; logQueryFields: LogQueryFields; + compositeSize: number; lookback: Unit; alertInterval: string; alertThrottle: string; @@ -46,6 +47,7 @@ export const previewInventoryMetricThresholdAlert: ( params, source, logQueryFields, + compositeSize, lookback, alertInterval, alertThrottle, @@ -70,8 +72,17 @@ export const previewInventoryMetricThresholdAlert: ( try { const results = await Promise.all( - criteria.map((c) => - evaluateCondition(c, nodeType, source, logQueryFields, esClient, filterQuery, lookbackSize) + criteria.map((condition) => + evaluateCondition({ + condition, + nodeType, + source, + logQueryFields, + esClient, + compositeSize, + filterQuery, + lookbackSize, + }) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 9086d6436c2a2..44b2695ba4e3b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -424,9 +424,8 @@ describe('The metric threshold alert type', () => { const createMockStaticConfiguration = (sources: any) => ({ enabled: true, - query: { - partitionSize: 1, - partitionFactor: 1, + inventory: { + compositeSize: 2000, }, sources, }); diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 08e42279e4939..f338d7957a343 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { InfraSourceConfiguration } from '../../common/source_configuration/source_configuration'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; @@ -28,14 +27,3 @@ export interface InfraBackendLibs extends InfraDomainLibs { sourceStatus: InfraSourceStatus; getLogQueryFields: GetLogQueryFields; } - -export interface InfraConfiguration { - enabled: boolean; - query: { - partitionSize: number; - partitionFactor: number; - }; - sources: { - default: InfraSourceConfiguration; - }; -} diff --git a/x-pack/plugins/infra/server/lib/sources/sources.test.ts b/x-pack/plugins/infra/server/lib/sources/sources.test.ts index aaeb44bb03aa7..0786722e5a479 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.test.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.test.ts @@ -134,9 +134,8 @@ describe('the InfraSources lib', () => { const createMockStaticConfiguration = (sources: any) => ({ enabled: true, - query: { - partitionSize: 1, - partitionFactor: 1, + inventory: { + compositeSize: 2000, }, sources, }); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 50fec38b9f2df..f818776fdf82c 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -35,9 +35,8 @@ import { createGetLogQueryFields } from './services/log_queries/get_log_query_fi export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), - query: schema.object({ - partitionSize: schema.number({ defaultValue: 75 }), - partitionFactor: schema.number({ defaultValue: 1.2 }), + inventory: schema.object({ + compositeSize: schema.number({ defaultValue: 2000 }), }), sources: schema.maybe( schema.object({ diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 4d980834d3a70..3008504f3b06c 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -29,6 +29,7 @@ export const initAlertPreviewRoute = ({ framework, sources, getLogQueryFields, + configuration, }: InfraBackendLibs) => { framework.registerRoute( { @@ -56,6 +57,8 @@ export const initAlertPreviewRoute = ({ sourceId || 'default' ); + const compositeSize = configuration.inventory.compositeSize; + try { switch (alertType) { case METRIC_THRESHOLD_ALERT_TYPE_ID: { @@ -96,6 +99,7 @@ export const initAlertPreviewRoute = ({ lookback, source, logQueryFields, + compositeSize, alertInterval, alertThrottle, alertNotifyWhen, diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index cbadd26ccd4bf..c5394a02c1d04 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -40,7 +40,7 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { requestContext.core.savedObjects.client, snapshotRequest.sourceId ); - + const compositeSize = libs.configuration.inventory.compositeSize; const logQueryFields = await libs.getLogQueryFields( snapshotRequest.sourceId, requestContext.core.savedObjects.client @@ -49,7 +49,13 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { UsageCollector.countNode(snapshotRequest.nodeType); const client = createSearchClient(requestContext, framework); - const snapshotResponse = await getNodes(client, snapshotRequest, source, logQueryFields); + const snapshotResponse = await getNodes( + client, + snapshotRequest, + source, + logQueryFields, + compositeSize + ); return response.ok({ body: SnapshotNodeResponseRT.encode(snapshotResponse), diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts index ff3cf048b99de..21420095a3ae5 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts @@ -19,18 +19,26 @@ export interface SourceOverrides { timestamp: string; } -const transformAndQueryData = async ( - client: ESSearchClient, - snapshotRequest: SnapshotRequest, - source: InfraSource, - sourceOverrides?: SourceOverrides -) => { - const metricsApiRequest = await transformRequestToMetricsAPIRequest( +const transformAndQueryData = async ({ + client, + snapshotRequest, + source, + compositeSize, + sourceOverrides, +}: { + client: ESSearchClient; + snapshotRequest: SnapshotRequest; + source: InfraSource; + compositeSize: number; + sourceOverrides?: SourceOverrides; +}) => { + const metricsApiRequest = await transformRequestToMetricsAPIRequest({ client, source, snapshotRequest, - sourceOverrides - ); + compositeSize, + sourceOverrides, + }); const metricsApiResponse = await queryAllData(client, metricsApiRequest); const snapshotResponse = transformMetricsApiResponseToSnapshotResponse( metricsApiRequest, @@ -45,30 +53,39 @@ export const getNodes = async ( client: ESSearchClient, snapshotRequest: SnapshotRequest, source: InfraSource, - logQueryFields: LogQueryFields + logQueryFields: LogQueryFields, + compositeSize: number ) => { let nodes; if (snapshotRequest.metrics.find((metric) => metric.type === 'logRate')) { // *Only* the log rate metric has been requested if (snapshotRequest.metrics.length === 1) { - nodes = await transformAndQueryData(client, snapshotRequest, source, logQueryFields); + nodes = await transformAndQueryData({ + client, + snapshotRequest, + source, + compositeSize, + sourceOverrides: logQueryFields, + }); } else { // A scenario whereby a single host might be shipping metrics and logs. const metricsWithoutLogsMetrics = snapshotRequest.metrics.filter( (metric) => metric.type !== 'logRate' ); - const nodesWithoutLogsMetrics = await transformAndQueryData( + const nodesWithoutLogsMetrics = await transformAndQueryData({ client, - { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, - source - ); - const logRateNodes = await transformAndQueryData( + snapshotRequest: { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, + source, + compositeSize, + }); + const logRateNodes = await transformAndQueryData({ client, - { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, + snapshotRequest: { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, source, - logQueryFields - ); + compositeSize, + sourceOverrides: logQueryFields, + }); // Merge nodes where possible - e.g. a single host is shipping metrics and logs const mergedNodes = nodesWithoutLogsMetrics.nodes.map((node) => { const logRateNode = logRateNodes.nodes.find( @@ -91,7 +108,7 @@ export const getNodes = async ( }; } } else { - nodes = await transformAndQueryData(client, snapshotRequest, source); + nodes = await transformAndQueryData({ client, snapshotRequest, source, compositeSize }); } return nodes; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.ts new file mode 100644 index 0000000000000..1e1c202b7e602 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformRequestToMetricsAPIRequest } from './transform_request_to_metrics_api_request'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { InfraSource } from '../../../lib/sources'; +import { SnapshotRequest } from '../../../../common/http_api'; + +jest.mock('./create_timerange_with_interval', () => { + return { + createTimeRangeWithInterval: () => ({ + interval: '60s', + from: 1605705900000, + to: 1605706200000, + }), + }; +}); + +describe('transformRequestToMetricsAPIRequest', () => { + test('returns a MetricsApiRequest given parameters', async () => { + const compositeSize = 3000; + const result = await transformRequestToMetricsAPIRequest({ + client: {} as ESSearchClient, + source, + snapshotRequest, + compositeSize, + }); + expect(result).toEqual(metricsApiRequest); + }); +}); + +const source: InfraSource = { + id: 'default', + version: 'WzkzNjk5LDVd', + updatedAt: 1617384456384, + origin: 'stored', + configuration: { + name: 'Default', + description: '', + metricAlias: 'metrics-*,metricbeat-*', + logAlias: 'logs-*,filebeat-*,kibana_sample_data_logs*', + fields: { + container: 'container.id', + host: 'host.name', + message: ['message', '@message'], + pod: 'kubernetes.pod.uid', + tiebreaker: '_doc', + timestamp: '@timestamp', + }, + inventoryDefaultView: '0', + metricsExplorerDefaultView: '0', + logColumns: [ + { timestampColumn: { id: '5e7f964a-be8a-40d8-88d2-fbcfbdca0e2f' } }, + { fieldColumn: { id: ' eb9777a8-fcd3-420e-ba7d-172fff6da7a2', field: 'event.dataset' } }, + { messageColumn: { id: 'b645d6da-824b-4723-9a2a-e8cece1645c0' } }, + { fieldColumn: { id: '906175e0-a293-42b2-929f-87a203e6fbec', field: 'agent.name' } }, + ], + anomalyThreshold: 50, + }, +}; + +const snapshotRequest: SnapshotRequest = { + metrics: [{ type: 'cpu' }], + groupBy: [], + nodeType: 'pod', + timerange: { interval: '1m', to: 1605706200000, from: 1605705000000, lookbackSize: 5 }, + filterQuery: '', + sourceId: 'default', + accountId: '', + region: '', + includeTimeseries: true, +}; + +const metricsApiRequest = { + indexPattern: 'metrics-*,metricbeat-*', + timerange: { field: '@timestamp', from: 1605705900000, to: 1605706200000, interval: '60s' }, + metrics: [ + { + id: 'cpu', + aggregations: { + cpu_with_limit: { avg: { field: 'kubernetes.pod.cpu.usage.limit.pct' } }, + cpu_without_limit: { avg: { field: 'kubernetes.pod.cpu.usage.node.pct' } }, + cpu: { + bucket_script: { + buckets_path: { with_limit: 'cpu_with_limit', without_limit: 'cpu_without_limit' }, + script: { + source: 'params.with_limit > 0.0 ? params.with_limit : params.without_limit', + lang: 'painless', + }, + gap_policy: 'skip', + }, + }, + }, + }, + { + id: '__metadata__', + aggregations: { + __metadata__: { + top_metrics: { + metrics: [{ field: 'kubernetes.pod.name' }, { field: 'kubernetes.pod.ip' }], + size: 1, + sort: { '@timestamp': 'desc' }, + }, + }, + }, + }, + ], + limit: 3000, + alignDataToEnd: true, + groupBy: ['kubernetes.pod.uid'], +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts index a71e1fb1f1f14..811b0da952456 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts @@ -15,12 +15,19 @@ import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapsho import { META_KEY } from './constants'; import { SourceOverrides } from './get_nodes'; -export const transformRequestToMetricsAPIRequest = async ( - client: ESSearchClient, - source: InfraSource, - snapshotRequest: SnapshotRequest, - sourceOverrides?: SourceOverrides -): Promise => { +export const transformRequestToMetricsAPIRequest = async ({ + client, + source, + snapshotRequest, + compositeSize, + sourceOverrides, +}: { + client: ESSearchClient; + source: InfraSource; + snapshotRequest: SnapshotRequest; + compositeSize: number; + sourceOverrides?: SourceOverrides; +}): Promise => { const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, { ...snapshotRequest, filterQuery: parseFilterQuery(snapshotRequest.filterQuery), @@ -36,7 +43,9 @@ export const transformRequestToMetricsAPIRequest = async ( interval: timeRangeWithIntervalApplied.interval, }, metrics: transformSnapshotMetricsToMetricsAPIMetrics(snapshotRequest), - limit: snapshotRequest.overrideCompositeSize ? snapshotRequest.overrideCompositeSize : 5, + limit: snapshotRequest.overrideCompositeSize + ? snapshotRequest.overrideCompositeSize + : compositeSize, alignDataToEnd: true, }; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index c15aa8f414fb1..a64a0c0ae09fe 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -10,6 +10,7 @@ export { ChartData } from './types/field_histograms'; export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies'; export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; export { isPopulatedObject } from './util/object_utils'; -export { isRuntimeMappings } from './util/runtime_field_utils'; export { composeValidators, patternValidator } from './util/validators'; +export { isRuntimeMappings, isRuntimeField } from './util/runtime_field_utils'; export { extractErrorMessage } from './util/errors'; +export type { RuntimeMappings } from './types/fields'; diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index 8dfe9d111ed38..45fcfac7e930c 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -28,7 +28,7 @@ export interface Field { aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; - runtimeField?: RuntimeField; + runtimeField?: estypes.RuntimeField; } export interface Aggregation { @@ -108,17 +108,4 @@ export interface AggCardinality { export type RollupFields = Record]>; -// Replace this with import once #88995 is merged -export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; -export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; - -export interface RuntimeField { - type: RuntimeType; - script?: - | string - | { - source: string; - }; -} - export type RuntimeMappings = estypes.RuntimeFields; diff --git a/x-pack/plugins/ml/common/util/runtime_field_utils.ts b/x-pack/plugins/ml/common/util/runtime_field_utils.ts index 6d911ecd5d3cb..7be2a3ec8c9e1 100644 --- a/x-pack/plugins/ml/common/util/runtime_field_utils.ts +++ b/x-pack/plugins/ml/common/util/runtime_field_utils.ts @@ -4,14 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { estypes } from '@elastic/elasticsearch'; import { isPopulatedObject } from './object_utils'; import { RUNTIME_FIELD_TYPES } from '../../../../../src/plugins/data/common'; -import type { RuntimeField, RuntimeMappings } from '../types/fields'; +import type { RuntimeMappings } from '../types/fields'; type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; -export function isRuntimeField(arg: unknown): arg is RuntimeField { +export function isRuntimeField(arg: unknown): arg is estypes.RuntimeField { return ( ((isPopulatedObject(arg, ['type']) && Object.keys(arg).length === 1) || (isPopulatedObject(arg, ['type', 'script']) && diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index d3e58c4d7bb0d..f723c1d72b818 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from 'src/core/public'; +import type { estypes } from '@elastic/elasticsearch'; import { IndexPattern, IFieldType, @@ -49,7 +50,7 @@ import { getNestedProperty } from '../../util/object_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; import { DataGridItem, IndexPagination, RenderCellValue } from './types'; -import { RuntimeMappings, RuntimeField } from '../../../../common/types/fields'; +import { RuntimeMappings } from '../../../../common/types/fields'; import { isRuntimeMappings } from '../../../../common/util/runtime_field_utils'; export const INIT_MAX_COLUMNS = 10; @@ -179,7 +180,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results export const NON_AGGREGATABLE = 'non-aggregatable'; export const getDataGridSchemaFromESFieldType = ( - fieldType: ES_FIELD_TYPES | undefined | RuntimeField['type'] + fieldType: ES_FIELD_TYPES | undefined | estypes.RuntimeField['type'] ): string | undefined => { // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] // To fall back to the default string schema it needs to be undefined. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts index 1e1f376049579..79986e8ddb098 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts @@ -6,8 +6,8 @@ */ import { i18n } from '@kbn/i18n'; +import { estypes } from '@elastic/elasticsearch'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { RuntimeType } from '../../../../../../../../../../src/plugins/data/common'; import { EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; @@ -18,7 +18,7 @@ export const CATEGORICAL_TYPES = new Set(['ip', 'keyword']); // Regression supports numeric fields. Classification supports categorical, numeric, and boolean. export const shouldAddAsDepVarOption = ( fieldId: string, - fieldType: ES_FIELD_TYPES | RuntimeType, + fieldType: ES_FIELD_TYPES | estypes.RuntimeField['type'], jobType: AnalyticsJobType ) => { if (fieldId === EVENT_RATE_FIELD_ID) return false; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx index d21bf67a1f51c..5b8fc82ef587b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx @@ -131,7 +131,7 @@ export const RuntimeMappings: FC = ({ actions, state }) => { defaultMessage: 'Runtime mappings', })} > - + {isPopulatedObject(runtimeMappings) ? ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index f48f4a62f5a7d..2d9ae1cd4689b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -13,7 +13,7 @@ import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { isRuntimeMappings } from '../../../../../../common/util/runtime_field_utils'; -import { RuntimeMappings, RuntimeField } from '../../../../../../common/types/fields'; +import { RuntimeMappings } from '../../../../../../common/types/fields'; import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../../common/constants/field_histograms'; import { DataLoader } from '../../../../datavisualizer/index_based/data_loader'; @@ -44,7 +44,7 @@ interface MLEuiDataGridColumn extends EuiDataGridColumn { function getRuntimeFieldColumns(runtimeMappings: RuntimeMappings) { return Object.keys(runtimeMappings).map((id) => { const field = runtimeMappings[id]; - const schema = getDataGridSchemaFromESFieldType(field.type as RuntimeField['type']); + const schema = getDataGridSchemaFromESFieldType(field.type as estypes.RuntimeField['type']); return { id, schema, isExpandable: schema !== 'boolean', isRuntimeFieldColumn: true }; }); } @@ -64,7 +64,7 @@ export const useIndexData = ( const field = indexPattern.fields.getByName(id); const isRuntimeFieldColumn = field?.runtimeField !== undefined; const schema = isRuntimeFieldColumn - ? getDataGridSchemaFromESFieldType(field?.type as RuntimeField['type']) + ? getDataGridSchemaFromESFieldType(field?.type as estypes.RuntimeField['type']) : getDataGridSchemaFromKibanaFieldType(field); return { id, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx new file mode 100644 index 0000000000000..858ab58b53f4b --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.test.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; + +import { IntlProvider } from 'react-intl'; + +import { + getIndexPatternAndSavedSearch, + IndexPatternAndSavedSearch, +} from '../../../../../util/index_utils'; + +import { SourceSelection } from './source_selection'; + +jest.mock('../../../../../../../../../../src/plugins/saved_objects/public', () => { + const SavedObjectFinderUi = ({ + onChoose, + }: { + onChoose: (id: string, type: string, fullName: string, savedObject: object) => void; + }) => { + return ( + <> + + + + + + ); + }; + + return { + SavedObjectFinderUi, + }; +}); + +const mockNavigateToPath = jest.fn(); +jest.mock('../../../../../contexts/kibana', () => ({ + useMlKibana: () => ({ + services: { + savedObjects: {}, + uiSettings: {}, + }, + }), + useNavigateToPath: () => mockNavigateToPath, +})); + +jest.mock('../../../../../util/index_utils', () => { + return { + getIndexPatternAndSavedSearch: jest.fn( + async (id: string): Promise => { + return { + indexPattern: { + fields: [], + title: + id === 'the-remote-saved-search-id' + ? 'my_remote_cluster:index-pattern-title' + : 'index-pattern-title', + }, + savedSearch: null, + }; + } + ), + }; +}); + +const mockOnClose = jest.fn(); +const mockGetIndexPatternAndSavedSearch = getIndexPatternAndSavedSearch as jest.Mock; + +describe('Data Frame Analytics: ', () => { + afterEach(() => { + mockNavigateToPath.mockClear(); + mockGetIndexPatternAndSavedSearch.mockClear(); + }); + + it('renders the title text', async () => { + // prepare + render( + + + + ); + + // assert + expect(screen.queryByText('New analytics job')).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + + it('shows the error callout when clicking a remote index pattern', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('RemoteIndexPattern', { selector: 'button' })); + await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut')); + + // assert + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + + it('calls navigateToPath for a plain index pattern ', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('PlainIndexPattern', { selector: 'button' })); + + // assert + await waitFor(() => { + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).not.toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledWith( + '/data_frame_analytics/new_job?index=the-plain-index-pattern-id' + ); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0); + }); + }); + + it('shows the error callout when clicking a saved search using a remote index pattern', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('RemoteSavedSearch', { selector: 'button' })); + await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut')); + + // assert + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).toBeInTheDocument(); + expect( + screen.queryByText( + `The saved search 'the-remote-saved-search-title' uses the index pattern 'my_remote_cluster:index-pattern-title'.` + ) + ).toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledTimes(0); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-remote-saved-search-id'); + }); + + it('calls navigateToPath for a saved search using a plain index pattern ', async () => { + // prepare + render( + + + + ); + + // act + fireEvent.click(screen.getByText('PlainSavedSearch', { selector: 'button' })); + + // assert + await waitFor(() => { + expect( + screen.queryByText('Index patterns using cross-cluster search are not supported.') + ).not.toBeInTheDocument(); + expect(mockNavigateToPath).toHaveBeenCalledWith( + '/data_frame_analytics/new_job?savedSearchId=the-plain-saved-search-id' + ); + expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-plain-saved-search-id'); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx index 40f97690d7790..cbc5a226eb319 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx @@ -5,15 +5,28 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { useState, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; +import { + EuiCallOut, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, +} from '@elastic/eui'; + +import type { SimpleSavedObject } from 'src/core/public'; import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public'; import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana'; +import { getNestedProperty } from '../../../../../util/object_utils'; + +import { getIndexPatternAndSavedSearch } from '../../../../../util/index_utils'; + const fixedPageSize: number = 8; interface Props { @@ -26,7 +39,49 @@ export const SourceSelection: FC = ({ onClose }) => { } = useMlKibana(); const navigateToPath = useNavigateToPath(); - const onSearchSelected = async (id: string, type: string) => { + const [isCcsCallOut, setIsCcsCallOut] = useState(false); + const [ccsCallOutBodyText, setCcsCallOutBodyText] = useState(); + + const onSearchSelected = async ( + id: string, + type: string, + fullName: string, + savedObject: SimpleSavedObject + ) => { + // Kibana index patterns including `:` are cross-cluster search indices + // and are not supported by Data Frame Analytics yet. For saved searches + // and index patterns that use cross-cluster search we intercept + // the selection before redirecting and show an error callout instead. + let indexPatternTitle = ''; + + if (type === 'index-pattern') { + indexPatternTitle = getNestedProperty(savedObject, 'attributes.title'); + } else if (type === 'search') { + const indexPatternAndSavedSearch = await getIndexPatternAndSavedSearch(id); + indexPatternTitle = indexPatternAndSavedSearch.indexPattern?.title ?? ''; + } + + if (indexPatternTitle.includes(':')) { + setIsCcsCallOut(true); + if (type === 'search') { + setCcsCallOutBodyText( + i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.CcsErrorCallOutBody', + { + defaultMessage: `The saved search '{savedSearchTitle}' uses the index pattern '{indexPatternTitle}'.`, + values: { + savedSearchTitle: getNestedProperty(savedObject, 'attributes.title'), + indexPatternTitle, + }, + } + ) + ); + } else { + setCcsCallOutBodyText(undefined); + } + return; + } + await navigateToPath( `/data_frame_analytics/new_job?${ type === 'index-pattern' ? 'index' : 'savedSearchId' @@ -54,6 +109,23 @@ export const SourceSelection: FC = ({ onClose }) => { + {isCcsCallOut && ( + <> + + {typeof ccsCallOutBodyText === 'string' &&

{ccsCallOutBodyText}

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

- + + +

+ +

+
+
+ + + -

-
+
+
{ + explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA }); + }, clearInfluencerFilterSettings: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS }); }, @@ -137,6 +140,9 @@ export const explorerService = { setFilterData: (payload: Partial>) => { explorerAction$.next(setFilterDataActionCreator(payload)); }, + setChartsDataLoading: () => { + explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING }); + }, setSwimlaneContainerWidth: (payload: number) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 9e24a4349584e..b410449218d02 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -12,6 +12,7 @@ import { TimeRangeBounds } from '../util/time_buckets'; import { RecordForInfluencer } from '../services/results_service/results_service'; import { InfluencersFilterQuery } from '../../../common/types/es_client'; import { MlResultsService } from '../services/results_service'; +import { EntityField } from '../../../common/util/anomaly_utils'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -60,7 +61,7 @@ export declare const getSelectionJobIds: ( export declare const getSelectionInfluencers: ( selectedCells: AppStateSelectedCells | undefined, fieldName: string -) => string[]; +) => EntityField[]; interface SelectionTimeRange { earliestMs: number; @@ -149,6 +150,7 @@ export declare const loadDataForCharts: ( ) => Promise; export declare const loadFilteredTopInfluencers: ( + mlResultsService: MlResultsService, jobIds: string[], earliestMs: number, latestMs: number, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index ea101d104f783..69bdac060a2dc 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -536,65 +536,6 @@ export async function loadAnomaliesTableData( }); } -// track the request to be able to ignore out of date requests -// and avoid race conditions ending up with the wrong charts. -let requestCount = 0; -export async function loadDataForCharts( - mlResultsService, - jobIds, - earliestMs, - latestMs, - influencers = [], - selectedCells, - influencersFilterQuery, - // choose whether or not to keep track of the request that could be out of date - // in Anomaly Explorer this is being used to ignore any request that are out of date - // but in embeddables, we might have multiple requests coming from multiple different panels - takeLatestOnly = true -) { - return new Promise((resolve) => { - // Just skip doing the request when this function - // is called without the minimum required data. - if ( - selectedCells === undefined && - influencers.length === 0 && - influencersFilterQuery === undefined - ) { - resolve([]); - } - - const newRequestCount = ++requestCount; - requestCount = newRequestCount; - - // Load the top anomalies (by record_score) which will be displayed in the charts. - mlResultsService - .getRecordsForInfluencer( - jobIds, - influencers, - 0, - earliestMs, - latestMs, - 500, - influencersFilterQuery - ) - .then((resp) => { - // Ignore this response if it's returned by an out of date promise - if (takeLatestOnly && newRequestCount < requestCount) { - resolve([]); - } - - if ( - (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || - influencersFilterQuery !== undefined - ) { - resolve(resp.records); - } - - resolve([]); - }); - }); -} - export async function loadTopInfluencers( mlResultsService, selectedJobIds, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index f66cd94314608..15e0caa29af39 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -20,7 +20,7 @@ import { import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; import { jobSelectionChange } from './job_selection_change'; -import { ExplorerState } from './state'; +import { ExplorerState, getExplorerDefaultState } from './state'; import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; import { getTimeBoundsFromSelection } from '../../hooks/use_selected_cells'; @@ -31,6 +31,10 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo let nextState: ExplorerState; switch (type) { + case EXPLORER_ACTION.CLEAR_EXPLORER_DATA: + nextState = getExplorerDefaultState(); + break; + case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS: nextState = clearInfluencerFilterSettings(state); break; @@ -49,6 +53,14 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = jobSelectionChange(state, payload); break; + case EXPLORER_ACTION.SET_CHARTS_DATA_LOADING: + nextState = { + ...state, + anomalyChartsDataLoading: true, + chartsData: getDefaultChartsData(), + }; + break; + case EXPLORER_ACTION.SET_CHARTS: nextState = { ...state, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index bb90fedfc2315..e9527b7c232e5 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -28,6 +28,7 @@ import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export interface ExplorerState { annotations: AnnotationsTable; + anomalyChartsDataLoading: boolean; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; filterActive: boolean; @@ -69,6 +70,7 @@ export function getExplorerDefaultState(): ExplorerState { annotationsData: [], aggregations: {}, }, + anomalyChartsDataLoading: true, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, filterActive: false, diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index b651b311f13aa..3e5cf252230a2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -159,6 +159,14 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [JSON.stringify(jobIds)]); + useEffect(() => { + return () => { + // upon component unmounting + // clear any data to prevent next page from rendering old charts + explorerService.clearExplorerData(); + }; + }, []); + /** * TODO get rid of the intermediate state in explorerService. * URL state should be the only source of truth for related props. diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts index 21f07ed9e5a3c..28140038d249b 100644 --- a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts @@ -10,4 +10,5 @@ export const createAnomalyExplorerChartsServiceMock = () => ({ getAnomalyData: jest.fn(), setTimeRange: jest.fn(), getTimeBounds: jest.fn(), + loadDataForCharts$: jest.fn(), }); diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts index 36e18b49cfa84..ac61e11b1128e 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts @@ -13,7 +13,6 @@ import { of } from 'rxjs'; import { cloneDeep } from 'lodash'; import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; -import type { ExplorerService } from '../explorer/explorer_dashboard_service'; import type { MlApiServices } from './ml_api_service'; import type { MlResultsService } from './results_service'; import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; @@ -89,9 +88,6 @@ describe('AnomalyExplorerChartsService', () => { (mlApiServicesMock as unknown) as MlApiServices, (mlResultsServiceMock as unknown) as MlResultsService ); - const explorerService = { - setCharts: jest.fn(), - }; const timeRange = { earliestMs: 1486656000000, @@ -104,13 +100,8 @@ describe('AnomalyExplorerChartsService', () => { ); }); - afterEach(() => { - explorerService.setCharts.mockClear(); - }); - test('should return anomaly data without explorer service', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecords, @@ -123,27 +114,8 @@ describe('AnomalyExplorerChartsService', () => { assertAnomalyDataResult(anomalyData); }); - test('should set anomaly data with explorer service side effects', async () => { - await anomalyExplorerService.getAnomalyData( - (explorerService as unknown) as ExplorerService, - (combinedJobRecords as unknown) as Record, - 1000, - mockAnomalyChartRecords, - timeRange.earliestMs, - timeRange.latestMs, - timefilterMock, - 0, - 12 - ); - - expect(explorerService.setCharts.mock.calls.length).toBe(2); - assertAnomalyDataResult(explorerService.setCharts.mock.calls[0][0]); - assertAnomalyDataResult(explorerService.setCharts.mock.calls[1][0]); - }); - test('call anomalyChangeListener with empty series config', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, // @ts-ignore (combinedJobRecords as unknown) as Record, 1000, @@ -165,7 +137,6 @@ describe('AnomalyExplorerChartsService', () => { mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; const anomalyData = (await anomalyExplorerService.getAnomalyData( - undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecordsClone, diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index 72de5d003d4b8..7aff2ff7e0026 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -7,6 +7,8 @@ import { each, find, get, map, reduce, sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { Observable, of } from 'rxjs'; +import { map as mapObservable } from 'rxjs/operators'; import { RecordForInfluencer } from './results_service/results_service'; import { isMappableJob, @@ -29,7 +31,6 @@ import { CHART_TYPE, ChartType } from '../explorer/explorer_constants'; import type { ChartRecord } from '../explorer/explorer_utils'; import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx'; import { isPopulatedObject } from '../../../common/util/object_utils'; -import type { ExplorerService } from '../explorer/explorer_dashboard_service'; import { AnomalyRecordDoc } from '../../../common/types/anomalies'; import { ExplorerChartsData, @@ -37,6 +38,8 @@ import { } from '../explorer/explorer_charts/explorer_charts_container_service'; import { TimeRangeBounds } from '../util/time_buckets'; import { isDefined } from '../../../common/types/guards'; +import { AppStateSelectedCells } from '../explorer/explorer_utils'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; const CHART_MAX_POINTS = 500; const ANOMALIES_MAX_RESULTS = 500; const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. @@ -370,15 +373,53 @@ export class AnomalyExplorerChartsService { // Getting only necessary job config and datafeed config without the stats jobIds.map((jobId) => this.mlApiServices.jobs.jobForCloning(jobId)) ); - const combinedJobs = combinedResults + return combinedResults .filter(isDefined) .filter((r) => r.job !== undefined && r.datafeed !== undefined) .map(({ job, datafeed }) => ({ ...job, datafeed_config: datafeed } as CombinedJob)); - return combinedJobs; + } + + public loadDataForCharts$( + jobIds: string[], + earliestMs: number, + latestMs: number, + influencers: EntityField[] = [], + selectedCells: AppStateSelectedCells | undefined, + influencersFilterQuery: InfluencersFilterQuery + ): Observable { + if ( + selectedCells === undefined && + influencers.length === 0 && + influencersFilterQuery === undefined + ) { + of([]); + } + + return this.mlResultsService + .getRecordsForInfluencer$( + jobIds, + influencers, + 0, + earliestMs, + latestMs, + 500, + influencersFilterQuery + ) + .pipe( + mapObservable((resp): RecordForInfluencer[] => { + if ( + (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || + influencersFilterQuery !== undefined + ) { + return resp.records; + } + + return [] as RecordForInfluencer[]; + }) + ); } public async getAnomalyData( - explorerService: ExplorerService | undefined, combinedJobRecords: Record, chartsContainerWidth: number, anomalyRecords: ChartRecord[] | undefined, @@ -486,9 +527,6 @@ export class AnomalyExplorerChartsService { data.errorMessages = errorMessages; } - if (explorerService) { - explorerService.setCharts({ ...data }); - } if (seriesConfigs.length === 0) { return data; } @@ -848,9 +886,6 @@ export class AnomalyExplorerChartsService { // push map data in if it's available data.seriesToPlot.push(...mapData); } - if (explorerService) { - explorerService.setCharts({ ...data }); - } return Promise.resolve(data); }) .catch((error) => { @@ -860,7 +895,7 @@ export class AnomalyExplorerChartsService { } public processRecordsForDisplay( - jobRecords: Record, + combinedJobRecords: Record, anomalyRecords: RecordForInfluencer[] ): { records: ChartRecord[]; errors: Record> | undefined } { // Aggregate the anomaly data by detector, and entity (by/over/partition). @@ -875,7 +910,7 @@ export class AnomalyExplorerChartsService { // Check if we can plot a chart for this record, depending on whether the source data // is chartable, and if model plot is enabled for the job. - const job = jobRecords[record.job_id]; + const job = combinedJobRecords[record.job_id]; // if we already know this job has datafeed aggregations we cannot support // no need to do more checks diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index e07d49ca23d3b..caa0e20c3230d 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -22,9 +22,11 @@ import { MlApiServices } from '../ml_api_service'; import { CriteriaField } from './index'; import { findAggField } from '../../../../common/util/validation_utils'; import { getDatafeedAggregations } from '../../../../common/util/datafeed_utils'; -import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; +import { aggregationTypeTransform, EntityField } from '../../../../common/util/anomaly_utils'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { isPopulatedObject } from '../../../../common/util/object_utils'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import { RecordForInfluencer } from './results_service'; interface ResultResponse { success: boolean; @@ -633,5 +635,135 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { latestMs ); }, + + // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), + // for the specified job(s), time range, and record score threshold. + // influencers parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, + // so this returns record level results which have at least one of the influencers. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForInfluencer$( + jobIds: string[], + influencers: EntityField[], + threshold: number, + earliestMs: number, + latestMs: number, + maxResults: number, + influencersFilterQuery: InfluencersFilterQuery + ): Observable<{ records: RecordForInfluencer[]; success: boolean }> { + const obj = { success: true, records: [] as RecordForInfluencer[] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } + + // Add a nested query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ + bool: { + should: influencers.map((influencer) => { + return { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencer.fieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencer.fieldValue, + }, + }, + ], + }, + }, + }, + }; + }), + minimum_should_match: 1, + }, + }); + } + + return mlApiServices.results + .anomalySearch$( + { + size: maxResults !== undefined ? maxResults : 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }, + jobIds + ) + .pipe( + map((resp) => { + if (resp.hits.total.value > 0) { + each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + return obj; + }) + ); + }, }; } diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index d26e650d145cb..6161eeb4e7940 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -55,7 +55,6 @@ export function resultsServiceProvider( influencersFilterQuery: InfluencersFilterQuery ): Promise; getRecordInfluencers(): Promise; - getRecordsForInfluencer(): Promise; getRecordsForDetector(): Promise; getRecords(): Promise; getEventRateData( diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index b041267f46c04..c258d07cab484 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -779,139 +779,6 @@ export function resultsServiceProvider(mlApiServices) { }); }, - // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), - // for the specified job(s), time range, and record score threshold. - // influencers parameter must be an array, with each object in the array having 'fieldName' - // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, - // so this returns record level results which have at least one of the influencers. - // Pass an empty array or ['*'] to search over all job IDs. - getRecordsForInfluencer( - jobIds, - influencers, - threshold, - earliestMs, - latestMs, - maxResults, - influencersFilterQuery - ) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a nested query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - match: { - 'influencers.influencer_field_name': influencer.fieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencer.fieldValue, - }, - }, - ], - }, - }, - }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - mlApiServices.results - .anomalySearch( - { - size: maxResults !== undefined ? maxResults : 100, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }, - jobIds - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - }, - // Queries Elasticsearch to obtain the record level results for the specified job and detector, // time range, record score threshold, and whether to only return results containing influencers. // An additional, optional influencer field name and value may also be provided. @@ -1039,14 +906,6 @@ export function resultsServiceProvider(mlApiServices) { }); }, - // Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, - // and record score threshold. - // Pass an empty array or ['*'] to search over all job IDs. - // Returned response contains a records property, which is an array of the matching results. - getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { - return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); - }, - // Queries Elasticsearch to obtain event rate data i.e. the count // of documents over time. // index can be a String, or String[], of index names to search. diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx index f32446fd6d9ab..a36d063737704 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx @@ -23,7 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyChartsEmbeddableInput } from '..'; import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../application/services/anomaly_explorer_charts_service'; -const MAX_SERIES_ALLOWED = 48; +export const MAX_ANOMALY_CHARTS_ALLOWED = 48; export interface AnomalyChartsInitializerProps { defaultTitle: string; initialInput?: Partial>; @@ -98,7 +98,7 @@ export const AnomalyChartsInitializer: FC = ({ value={maxSeriesToPlot} onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))} min={0} - max={MAX_SERIES_ALLOWED} + max={MAX_ANOMALY_CHARTS_ALLOWED} /> diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts index efac51edda69f..7045b2eac378a 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts @@ -29,41 +29,6 @@ jest.mock('../../application/explorer/explorer_utils', () => ({ }), getSelectionJobIds: jest.fn(() => ['test-job']), getSelectionTimeRange: jest.fn(() => ({ earliestMs: 1521309543000, latestMs: 1616003942999 })), - loadDataForCharts: jest.fn().mockImplementation(() => - Promise.resolve([ - { - job_id: 'cw_multi_1', - result_type: 'record', - probability: 6.057139142746412e-13, - multi_bucket_impact: -5, - record_score: 89.71961, - initial_record_score: 98.36826274948001, - bucket_span: 900, - detector_index: 0, - is_interim: false, - timestamp: 1572892200000, - partition_field_name: 'instance', - partition_field_value: 'i-d17dcd4c', - function: 'mean', - function_description: 'mean', - typical: [1.6177685422858146], - actual: [7.235333333333333], - field_name: 'CPUUtilization', - influencers: [ - { - influencer_field_name: 'region', - influencer_field_values: ['sa-east-1'], - }, - { - influencer_field_name: 'instance', - influencer_field_values: ['i-d17dcd4c'], - }, - ], - instance: ['i-d17dcd4c'], - region: ['sa-east-1'], - }, - ]) - ), })); describe('useAnomalyChartsInputResolver', () => { @@ -115,6 +80,42 @@ describe('useAnomalyChartsInputResolver', () => { }) ); + anomalyExplorerChartsServiceMock.loadDataForCharts$.mockImplementation(() => + Promise.resolve([ + { + job_id: 'cw_multi_1', + result_type: 'record', + probability: 6.057139142746412e-13, + multi_bucket_impact: -5, + record_score: 89.71961, + initial_record_score: 98.36826274948001, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1572892200000, + partition_field_name: 'instance', + partition_field_value: 'i-d17dcd4c', + function: 'mean', + function_description: 'mean', + typical: [1.6177685422858146], + actual: [7.235333333333333], + field_name: 'CPUUtilization', + influencers: [ + { + influencer_field_name: 'region', + influencer_field_values: ['sa-east-1'], + }, + { + influencer_field_name: 'instance', + influencer_field_values: ['i-d17dcd4c'], + }, + ], + instance: ['i-d17dcd4c'], + region: ['sa-east-1'], + }, + ]) + ); + const coreStartMock = createCoreStartMock(); const mlStartMock = createMlStartDepsMock(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts index b114ca89a3288..703851f3fe9b6 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -18,7 +18,6 @@ import { getSelectionInfluencers, getSelectionJobIds, getSelectionTimeRange, - loadDataForCharts, } from '../../application/explorer/explorer_utils'; import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { parseInterval } from '../../../common/util/parse_interval'; @@ -46,7 +45,7 @@ export function useAnomalyChartsInputResolver( const [ { uiSettings }, { data: dataServices }, - { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + { anomalyDetectorService, anomalyExplorerService }, ] = services; const { timefilter } = dataServices.query.timefilter; @@ -125,15 +124,13 @@ export function useAnomalyChartsInputResolver( const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds); return forkJoin({ combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds), - anomalyChartRecords: loadDataForCharts( - mlResultsService, + anomalyChartRecords: anomalyExplorerService.loadDataForCharts$( jobIds, timeRange.earliestMs, timeRange.latestMs, selectionInfluencers, selections, - influencersFilterQuery, - false + influencersFilterQuery ), }).pipe( switchMap(({ combinedJobs, anomalyChartRecords }) => { @@ -147,7 +144,6 @@ export function useAnomalyChartsInputResolver( return forkJoin({ chartsData: from( anomalyExplorerService.getAnomalyData( - undefined, combinedJobRecords, embeddableContainerWidth, anomalyChartRecords, diff --git a/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts new file mode 100644 index 0000000000000..e5c6a2345e167 --- /dev/null +++ b/x-pack/plugins/ml/public/ml_url_generator/__mocks__/ml_url_generator.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; +import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public'; + +export const createMlUrlGeneratorMock = () => + ({ + id: ML_APP_URL_GENERATOR, + isDeprecated: false, + createUrl: jest.fn(), + migrate: jest.fn(), + } as jest.Mocked>); diff --git a/x-pack/plugins/ml/public/mocks.ts b/x-pack/plugins/ml/public/mocks.ts new file mode 100644 index 0000000000000..6b55cb3b6b650 --- /dev/null +++ b/x-pack/plugins/ml/public/mocks.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createMlUrlGeneratorMock } from './ml_url_generator/__mocks__/ml_url_generator'; +import { MlPluginSetup, MlPluginStart } from './plugin'; +const createSetupContract = (): jest.Mocked => { + return { + urlGenerator: createMlUrlGeneratorMock(), + }; +}; + +const createStartContract = (): jest.Mocked => { + return { + urlGenerator: createMlUrlGeneratorMock(), + }; +}; + +export const mlPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/ml/server/mocks.ts b/x-pack/plugins/ml/server/mocks.ts new file mode 100644 index 0000000000000..e50f78a0fd99d --- /dev/null +++ b/x-pack/plugins/ml/server/mocks.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createJobServiceProviderMock } from './shared_services/providers/__mocks__/jobs_service'; +import { createAnomalyDetectorsProviderMock } from './shared_services/providers/__mocks__/anomaly_detectors'; +import { createMockMlSystemProvider } from './shared_services/providers/__mocks__/system'; +import { createModulesProviderMock } from './shared_services/providers/__mocks__/modules'; +import { createResultsServiceProviderMock } from './shared_services/providers/__mocks__/results_service'; +import { createAlertingServiceProviderMock } from './shared_services/providers/__mocks__/alerting_service'; +import { MlPluginSetup } from './plugin'; + +const createSetupContract = () => + (({ + jobServiceProvider: createJobServiceProviderMock(), + anomalyDetectorsProvider: createAnomalyDetectorsProviderMock(), + mlSystemProvider: createMockMlSystemProvider(), + modulesProvider: createModulesProviderMock(), + resultsServiceProvider: createResultsServiceProviderMock(), + alertingServiceProvider: createAlertingServiceProviderMock(), + } as unknown) as jest.Mocked); + +const createStartContract = () => jest.fn(); + +export const mlPluginServerMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 0287c2af11a7e..c6cf608fe1e0b 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -80,7 +80,7 @@ class FieldsService { if (firstKey !== undefined) { const field = fc[firstKey]; // add to the list of fields if the field type can be used by ML - if (supportedTypes.includes(field.type) === true) { + if (supportedTypes.includes(field.type) === true && field.metadata_field !== true) { fields.push({ id: k, name: k, diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts new file mode 100644 index 0000000000000..957321e61b83a --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/alerting_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const createAlertingServiceProviderMock = () => + jest.fn(() => ({ + preview: jest.fn(), + execute: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts new file mode 100644 index 0000000000000..12b12e4ba06df --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/anomaly_detectors.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyDetectorsProviderMock = () => + jest.fn(() => ({ + jobs: jest.fn(), + jobStats: jest.fn(), + datafeeds: jest.fn(), + datafeedStats: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts new file mode 100644 index 0000000000000..e39373d66eff8 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/jobs_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createJobServiceProviderMock = () => + jest.fn(() => ({ + jobsSummary: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts new file mode 100644 index 0000000000000..b33e11dae5879 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/modules.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createModulesProviderMock = () => + jest.fn(() => ({ + recognize: jest.fn(), + getModule: jest.fn(), + listModules: jest.fn(), + setup: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts new file mode 100644 index 0000000000000..7fd60d0b3428d --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/results_service.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createResultsServiceProviderMock = () => + jest.fn(() => ({ + getAnomaliesTableData: jest.fn(), + })); diff --git a/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts new file mode 100644 index 0000000000000..c002ddc4ced52 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/__mocks__/system.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createMockMlSystemProvider = () => + jest.fn(() => ({ + mlCapabilities: jest.fn(), + mlInfo: jest.fn(), + mlAnomalySearch: jest.fn(), + })); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js deleted file mode 100644 index 38672b4d59a20..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterAdd } from '../../../public/application/sections/remote_cluster_add'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - }, -}; - -const initTestBed = registerTestBed(RemoteClusterAdd, testBedConfig); - -export const setup = (props) => { - const testBed = initTestBed(props); - - // User actions - const clickSaveForm = async () => { - await act(async () => { - testBed.find('remoteClusterFormSaveButton').simulate('click'); - }); - - testBed.component.update(); - }; - - return { - ...testBed, - actions: { - clickSaveForm, - }, - }; -}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx new file mode 100644 index 0000000000000..a47e6c023a161 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { registerTestBed } from '@kbn/test/jest'; + +import { RemoteClusterAdd } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +const ComponentWithContext = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return ( + + + + ); +}; + +const testBedConfig = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + }, + defaultProps: { isCloudEnabled }, + }; +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig({ isCloudEnabled }))(); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js deleted file mode 100644 index 40abde35835f0..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { setupEnvironment } from '../helpers'; -import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; -import { setup } from './remote_clusters_add.helpers'; - -describe('Create Remote cluster', () => { - describe('on component mount', () => { - let find; - let exists; - let actions; - let form; - let server; - let component; - - beforeAll(() => { - ({ server } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); - }); - - beforeEach(async () => { - await act(async () => { - ({ form, exists, find, actions, component } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Add remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - test('should have a toggle to Skip unavailable remote cluster', () => { - expect(exists('remoteClusterFormSkipUnavailableFormToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - false - ); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormSkipUnavailableFormToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(true); - }); - - test('should have a toggle to enable "proxy" mode for a remote cluster', () => { - expect(exists('remoteClusterFormConnectionModeToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(false); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(true); - }); - - test('should display errors and disable the save button when clicking "save" without filling the form', async () => { - expect(exists('remoteClusterFormGlobalError')).toBe(false); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(false); - - await actions.clickSaveForm(); - - expect(exists('remoteClusterFormGlobalError')).toBe(true); - expect(form.getErrorsMessages()).toEqual([ - 'Name is required.', - 'At least one seed node is required.', - ]); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(true); - }); - }); - - describe('form validation', () => { - describe('remote cluster name', () => { - let component; - let actions; - let form; - - beforeEach(async () => { - await act(async () => { - ({ component, form, actions } = setup()); - }); - - component.update(); - }); - - test('should not allow spaces', async () => { - form.setInputValue('remoteClusterFormNameInput', 'with space'); - - await actions.clickSaveForm(); - - expect(form.getErrorsMessages()).toContain('Spaces are not allowed in the name.'); - }); - - test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { - const expectInvalidChar = (char) => { - if (char === '-' || char === '_') { - return; - } - - try { - form.setInputValue('remoteClusterFormNameInput', `with${char}`); - - expect(form.getErrorsMessages()).toContain( - `Remove the character ${char} from the name.` - ); - } catch { - throw Error(`Char "${char}" expected invalid but was allowed`); - } - }; - - await actions.clickSaveForm(); // display form errors - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); - }); - }); - - describe('seeds', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - form.setInputValue('remoteClusterFormNameInput', 'remote_cluster_test'); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setComboBoxValue('remoteClusterFormSeedsInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - - describe('proxy address', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - act(() => { - // Enable "proxy" mode - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setInputValue('remoteClusterFormProxyAddressInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts new file mode 100644 index 0000000000000..0727bc0c9ba2d --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SinonFakeServer } from 'sinon'; +import { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, RemoteClustersActions } from '../helpers'; +import { setup } from './remote_clusters_add.helpers'; +import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; + +const notInArray = (array: string[]) => (value: string) => array.indexOf(value) < 0; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +let server: SinonFakeServer; + +describe('Create Remote cluster', () => { + beforeAll(() => { + ({ server } = setupEnvironment()); + }); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + component.update(); + }); + + describe('on component mount', () => { + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Add remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + test('should have a toggle to Skip unavailable remote cluster', () => { + expect(actions.skipUnavailableSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.skipUnavailableSwitch.isChecked()).toBe(false); + + actions.skipUnavailableSwitch.toggle(); + + expect(actions.skipUnavailableSwitch.isChecked()).toBe(true); + }); + + describe('on prem', () => { + test('should have a toggle to enable "proxy" mode for a remote cluster', () => { + expect(actions.connectionModeSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.connectionModeSwitch.isChecked()).toBe(false); + + actions.connectionModeSwitch.toggle(); + + expect(actions.connectionModeSwitch.isChecked()).toBe(true); + }); + + test('server name has optional label', () => { + actions.connectionModeSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name (optional)'); + }); + + test('should display errors and disable the save button when clicking "save" without filling the form', async () => { + expect(actions.globalErrorExists()).toBe(false); + expect(actions.saveButton.isDisabled()).toBe(false); + + await actions.saveButton.click(); + + expect(actions.globalErrorExists()).toBe(true); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + // seeds input is switched on by default on prem and is required + 'At least one seed node is required.', + ]); + expect(actions.saveButton.isDisabled()).toBe(true); + }); + + test('renders no switch for cloud url input and proxy address + server name input modes', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(false); + }); + }); + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('renders a switch between cloud url input and proxy address + server name input for proxy connection', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(true); + }); + + test('renders no switch between sniff and proxy modes', () => { + expect(actions.connectionModeSwitch.exists()).toBe(false); + }); + test('defaults to cloud url input for proxy connection', () => { + expect(actions.cloudUrlSwitch.isChecked()).toBe(false); + }); + test('server name has no optional label', () => { + actions.cloudUrlSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name'); + }); + }); + }); + describe('form validation', () => { + describe('remote cluster name', () => { + test('should not allow spaces', async () => { + actions.nameInput.setValue('with space'); + + await actions.saveButton.click(); + + expect(actions.getErrorMessages()).toContain('Spaces are not allowed in the name.'); + }); + + test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { + const expectInvalidChar = (char: string) => { + if (char === '-' || char === '_') { + return; + } + + try { + actions.nameInput.setValue(`with${char}`); + + expect(actions.getErrorMessages()).toContain( + `Remove the character ${char} from the name.` + ); + } catch { + throw Error(`Char "${char}" expected invalid but was allowed`); + } + }; + + await actions.saveButton.click(); // display form errors + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); + }); + }); + + describe('proxy address', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.connectionModeSwitch.toggle(); + }); + + test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.proxyAddressInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.proxyAddressInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.proxyAddressInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + describe('on prem', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.nameInput.setValue('remote_cluster_test'); + }); + + describe('seeds', () => { + test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.seedsInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.seedsInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.seedsInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + test('server name is optional (proxy connection)', () => { + actions.connectionModeSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual(['A proxy address is required.']); + }); + }); + + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('cloud url is required since cloud url input is enabled by default', () => { + actions.saveButton.click(); + expect(actions.getErrorMessages()).toContain('A url is required.'); + }); + + test('proxy address and server name are required when cloud url input is disabled', () => { + actions.cloudUrlSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + 'A proxy address is required.', + 'A server name is required.', + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts similarity index 100% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js deleted file mode 100644 index 094fb5056e983..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterEdit } from '../../../public/application/sections/remote_cluster_edit'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; - -export const REMOTE_CLUSTER_EDIT = { - name: REMOTE_CLUSTER_EDIT_NAME, - seeds: ['localhost:9400'], - skipUnavailable: true, -}; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - // The remote cluster name to edit is read from the router ":id" param - // so we first set it in our initial entries - initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], - // and then we declarae the :id param on the component route path - componentRoutePath: '/:name', - }, -}; - -export const setup = registerTestBed(RemoteClusterEdit, testBedConfig); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx new file mode 100644 index 0000000000000..2259396bf33f2 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerTestBed, TestBedConfig } from '@kbn/test/jest'; + +import React from 'react'; +import { RemoteClusterEdit } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; + +export const REMOTE_CLUSTER_EDIT = { + name: REMOTE_CLUSTER_EDIT_NAME, + seeds: ['localhost:9400'], + skipUnavailable: true, +}; + +const ComponentWithContext = (props: { isCloudEnabled: boolean }) => { + const { isCloudEnabled, ...rest } = props; + return ( + + + + ); +}; + +const testBedConfig: TestBedConfig = { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + // The remote cluster name to edit is read from the router ":id" param + // so we first set it in our initial entries + initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], + // and then we declare the :id param on the component route path + componentRoutePath: '/:name', + }, +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig)({ isCloudEnabled }); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js deleted file mode 100644 index 19dd468cb76c5..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; -import { setupEnvironment } from '../helpers'; -import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; -import { - setup, - REMOTE_CLUSTER_EDIT, - REMOTE_CLUSTER_EDIT_NAME, -} from './remote_clusters_edit.helpers'; - -describe('Edit Remote cluster', () => { - let component; - let find; - let exists; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); - - httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); - - beforeEach(async () => { - await act(async () => { - ({ component, find, exists } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Edit remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - /** - * As the "edit" remote cluster component uses the same form underneath that - * the "create" remote cluster, we won't test it again but simply make sure that - * the form component is indeed shared between the 2 app sections. - */ - test('should use the same Form component as the "" component', async () => { - let addRemoteClusterTestBed; - - await act(async () => { - addRemoteClusterTestBed = setupRemoteClustersAdd(); - }); - - addRemoteClusterTestBed.component.update(); - - const formEdit = component.find(RemoteClusterForm); - const formAdd = addRemoteClusterTestBed.component.find(RemoteClusterForm); - - expect(formEdit.length).toBe(1); - expect(formAdd.length).toBe(1); - }); - - test('should populate the form fields with the values from the remote cluster loaded', () => { - expect(find('remoteClusterFormNameInput').props().value).toBe(REMOTE_CLUSTER_EDIT_NAME); - expect(find('remoteClusterFormSeedsInput').text()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - REMOTE_CLUSTER_EDIT.skipUnavailable - ); - }); - - test('should disable the form name input', () => { - expect(find('remoteClusterFormNameInput').props().disabled).toBe(true); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx new file mode 100644 index 0000000000000..2913de94bc2dd --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; + +import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; +import { RemoteClustersActions, setupEnvironment } from '../helpers'; +import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; +import { + setup, + REMOTE_CLUSTER_EDIT, + REMOTE_CLUSTER_EDIT_NAME, +} from './remote_clusters_edit.helpers'; +import { Cluster } from '../../../common/lib'; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +const { server, httpRequestsMockHelpers } = setupEnvironment(); + +describe('Edit Remote cluster', () => { + afterAll(() => { + server.restore(); + }); + + httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); + + beforeEach(async () => { + await act(async () => { + ({ component, actions } = await setup()); + }); + component.update(); + }); + + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Edit remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + /** + * As the "edit" remote cluster component uses the same form underneath that + * the "create" remote cluster, we won't test it again but simply make sure that + * the form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" component', async () => { + let addRemoteClusterTestBed: TestBed; + + await act(async () => { + addRemoteClusterTestBed = await setupRemoteClustersAdd(); + }); + + addRemoteClusterTestBed!.component.update(); + + const formEdit = component.find(RemoteClusterForm); + const formAdd = addRemoteClusterTestBed!.component.find(RemoteClusterForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should populate the form fields with the values from the remote cluster loaded', () => { + expect(actions.nameInput.getValue()).toBe(REMOTE_CLUSTER_EDIT_NAME); + // seeds input for sniff connection is not shown on Cloud + expect(actions.seedsInput.getValue()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); + expect(actions.skipUnavailableSwitch.isChecked()).toBe(REMOTE_CLUSTER_EDIT.skipUnavailable); + }); + + test('should disable the form name input', () => { + expect(actions.nameInput.isDisabled()).toBe(true); + }); + + describe('on cloud', () => { + const cloudUrl = 'cloud-url'; + const defaultCloudPort = '9400'; + test('existing cluster that defaults to cloud url (default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(true); + expect(actions.cloudUrlInput.getValue()).toBe(cloudUrl); + }); + + test('existing cluster that defaults to manual input (non-default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:9500`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + + test('existing cluster that defaults to manual input (proxy address is different from server name)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: 'another-value', + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts similarity index 63% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts index 304ec51986aba..3ebe3ab5738d6 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts @@ -5,25 +5,24 @@ * 2.0. */ -import sinon from 'sinon'; +import sinon, { SinonFakeServer } from 'sinon'; +import { Cluster } from '../../../common/lib'; // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server) => { - const mockResponse = (response) => [ +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const mockResponse = (response: Cluster[] | { itemsDeleted: string[]; errors: string[] }) => [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(response), ]; - const setLoadRemoteClustersResponse = (response) => { - server.respondWith('GET', '/api/remote_clusters', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + const setLoadRemoteClustersResponse = (response: Cluster[] = []) => { + server.respondWith('GET', '/api/remote_clusters', mockResponse(response)); }; - const setDeleteRemoteClusterResponse = (response) => { + const setDeleteRemoteClusterResponse = ( + response: { itemsDeleted: string[]; errors: string[] } = { itemsDeleted: [], errors: [] } + ) => { server.respondWith('DELETE', /api\/remote_clusters/, mockResponse(response)); }; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts similarity index 80% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts index 63084b21e3902..cf859ff6913f5 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { nextTick, getRandomString, findTestSubject } from '@kbn/test/jest'; export { setupEnvironment } from './setup_environment'; +export { createRemoteClustersActions, RemoteClustersActions } from './remote_clusters_actions'; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts new file mode 100644 index 0000000000000..ba0c424793838 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +export interface RemoteClustersActions { + docsButtonExists: () => boolean; + pageTitle: { + exists: () => boolean; + text: () => string; + }; + nameInput: { + setValue: (name: string) => void; + getValue: () => string; + isDisabled: () => boolean; + }; + skipUnavailableSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + connectionModeSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + cloudUrlSwitch: { + toggle: () => void; + exists: () => boolean; + isChecked: () => boolean; + }; + cloudUrlInput: { + exists: () => boolean; + getValue: () => string; + }; + seedsInput: { + setValue: (seed: string) => void; + getValue: () => string; + }; + proxyAddressInput: { + setValue: (proxyAddress: string) => void; + exists: () => boolean; + }; + serverNameInput: { + getLabel: () => string; + exists: () => boolean; + }; + saveButton: { + click: () => void; + isDisabled: () => boolean; + }; + getErrorMessages: () => string[]; + globalErrorExists: () => boolean; +} +export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersActions => { + const { form, exists, find, component } = testBed; + + const docsButtonExists = () => exists('remoteClusterDocsButton'); + const createPageTitleActions = () => { + const pageTitleSelector = 'remoteClusterPageTitle'; + return { + pageTitle: { + exists: () => exists(pageTitleSelector), + text: () => find(pageTitleSelector).text(), + }, + }; + }; + const createNameInputActions = () => { + const nameInputSelector = 'remoteClusterFormNameInput'; + return { + nameInput: { + setValue: (name: string) => form.setInputValue(nameInputSelector, name), + getValue: () => find(nameInputSelector).props().value, + isDisabled: () => find(nameInputSelector).props().disabled, + }, + }; + }; + + const createSkipUnavailableActions = () => { + const skipUnavailableToggleSelector = 'remoteClusterFormSkipUnavailableFormToggle'; + return { + skipUnavailableSwitch: { + exists: () => exists(skipUnavailableToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(skipUnavailableToggleSelector); + }); + component.update(); + }, + isChecked: () => find(skipUnavailableToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createConnectionModeActions = () => { + const connectionModeToggleSelector = 'remoteClusterFormConnectionModeToggle'; + return { + connectionModeSwitch: { + exists: () => exists(connectionModeToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(connectionModeToggleSelector); + }); + component.update(); + }, + isChecked: () => find(connectionModeToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createCloudUrlSwitchActions = () => { + const cloudUrlSelector = 'remoteClusterFormCloudUrlToggle'; + return { + cloudUrlSwitch: { + exists: () => exists(cloudUrlSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(cloudUrlSelector); + }); + component.update(); + }, + isChecked: () => find(cloudUrlSelector).props()['aria-checked'], + }, + }; + }; + + const createSeedsInputActions = () => { + const seedsInputSelector = 'remoteClusterFormSeedsInput'; + return { + seedsInput: { + setValue: (seed: string) => form.setComboBoxValue(seedsInputSelector, seed), + getValue: () => find(seedsInputSelector).text(), + }, + }; + }; + + const createProxyAddressActions = () => { + const proxyAddressSelector = 'remoteClusterFormProxyAddressInput'; + return { + proxyAddressInput: { + setValue: (proxyAddress: string) => form.setInputValue(proxyAddressSelector, proxyAddress), + exists: () => exists(proxyAddressSelector), + }, + }; + }; + + const createSaveButtonActions = () => { + const click = () => { + act(() => { + find('remoteClusterFormSaveButton').simulate('click'); + }); + + component.update(); + }; + const isDisabled = () => find('remoteClusterFormSaveButton').props().disabled; + return { saveButton: { click, isDisabled } }; + }; + + const createServerNameActions = () => { + const serverNameSelector = 'remoteClusterFormServerNameFormRow'; + return { + serverNameInput: { + getLabel: () => find('remoteClusterFormServerNameFormRow').find('label').text(), + exists: () => exists(serverNameSelector), + }, + }; + }; + + const globalErrorExists = () => exists('remoteClusterFormGlobalError'); + + const createCloudUrlInputActions = () => { + const cloudUrlInputSelector = 'remoteClusterFormCloudUrlInput'; + return { + cloudUrlInput: { + exists: () => exists(cloudUrlInputSelector), + getValue: () => find(cloudUrlInputSelector).props().value, + }, + }; + }; + return { + docsButtonExists, + ...createPageTitleActions(), + ...createNameInputActions(), + ...createSkipUnavailableActions(), + ...createConnectionModeActions(), + ...createCloudUrlSwitchActions(), + ...createSeedsInputActions(), + ...createCloudUrlInputActions(), + ...createProxyAddressActions(), + ...createServerNameActions(), + ...createSaveButtonActions(), + getErrorMessages: form.getErrorsMessages, + globalErrorExists, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts similarity index 95% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts index 97ad344a63cc4..084552c5e6abe 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts @@ -36,6 +36,8 @@ export const setupEnvironment = () => { notificationServiceMock.createSetupContract().toasts, fatalErrorsServiceMock.createSetupContract() ); + // This expects HttpSetup but we're giving it AxiosInstance. + // @ts-ignore initHttp(mockHttpClient); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/plugins/remote_clusters/public/application/app_context.tsx b/x-pack/plugins/remote_clusters/public/application/app_context.tsx index 7931001c6faee..528ec322f49e1 100644 --- a/x-pack/plugins/remote_clusters/public/application/app_context.tsx +++ b/x-pack/plugins/remote_clusters/public/application/app_context.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { createContext } from 'react'; +import React, { createContext, useContext } from 'react'; export interface Context { isCloudEnabled: boolean; + cloudBaseUrl: string; } export const AppContext = createContext({} as any); @@ -22,3 +23,10 @@ export const AppContextProvider = ({ }) => { return {children}; }; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('Cannot use outside of app context'); + + return ctx; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/index.d.ts b/x-pack/plugins/remote_clusters/public/application/index.d.ts index 167297cedf556..45f981b5f2bc5 100644 --- a/x-pack/plugins/remote_clusters/public/application/index.d.ts +++ b/x-pack/plugins/remote_clusters/public/application/index.d.ts @@ -12,7 +12,8 @@ export declare const renderApp: ( elem: HTMLElement | null, I18nContext: I18nStart['Context'], appDependencies: { - isCloudEnabled?: boolean; + isCloudEnabled: boolean; + cloudBaseUrl: string; }, history: ScopedHistory ) => ReturnType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap deleted file mode 100644 index 5f09193be90c2..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ /dev/null @@ -1,2017 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RemoteClusterForm proxy mode renders correct connection settings when user enables proxy mode 1`] = ` - - -
- - } - fullWidth={true} - title={ - -

- -

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

- - Name - -

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

- -

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

- - Connection mode - -

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

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

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

- -

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

- - Make remote cluster optional - -

-
-
- -
- -
-

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

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

- Name -

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

- Connection mode -

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

- Make remote cluster optional -

-
-
-

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

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

+ +

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

- -

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

- -

- - } - description={ - -

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

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

{cause[0]}

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

- -

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

+ +

+ + } + description={ + +

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

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

{cause[0]}

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

+ +

+ + } + description={ + + } + fullWidth + > + + } + helpText={ + + } + error={errorClusterName} + isInvalid={Boolean(areErrorsVisible && errorClusterName)} + fullWidth + > + this.onFieldsChange({ name: e.target.value })} + fullWidth + disabled={!isNew} + data-test-subj="remoteClusterFormNameInput" + /> + +
+ + + + {this.renderSkipUnavailable()} +
+ + {this.renderErrors()} + + + + {this.renderActions()} + + {this.renderSavingFeedback()} + + {isRequestVisible ? ( + this.setState({ isRequestVisible: false })} + /> + ) : null} +
+ ); + } +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx index 4e402b8b55a5b..2bcedc2bce458 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx @@ -24,13 +24,13 @@ import { Cluster, serializeCluster } from '../../../../../common/lib'; interface Props { close: () => void; - name: string; cluster: Cluster; } export class RequestFlyout extends PureComponent { render() { - const { name, close, cluster } = this.props; + const { close, cluster } = this.props; + const { name } = cluster; const endpoint = 'PUT _cluster/settings'; const payload = JSON.stringify(serializeCluster(cluster), null, 2); const request = `${endpoint}\n${payload}`; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts index 67a5d8f727f3e..6f3956a19f6a0 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts @@ -10,3 +10,10 @@ export { validateProxy } from './validate_proxy'; export { validateSeeds } from './validate_seeds'; export { validateSeed } from './validate_seed'; export { validateServerName } from './validate_server_name'; +export { validateCluster, ClusterErrors } from './validate_cluster'; +export { + isCloudUrlEnabled, + validateCloudUrl, + convertProxyConnectionToCloudUrl, + convertCloudUrlToProxyConnection, +} from './validate_cloud_url'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts new file mode 100644 index 0000000000000..599706ba85b02 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isCloudUrlEnabled, + validateCloudUrl, + convertCloudUrlToProxyConnection, + convertProxyConnectionToCloudUrl, + i18nTexts, +} from './validate_cloud_url'; + +describe('Cloud url', () => { + describe('validation', () => { + it('errors when the url is empty', () => { + const actual = validateCloudUrl(''); + expect(actual).toBe(i18nTexts.urlEmpty); + }); + + it('errors when the url is invalid', () => { + const actual = validateCloudUrl('invalid%url'); + expect(actual).toBe(i18nTexts.urlInvalid); + }); + }); + + describe('is cloud url', () => { + it('true for a new cluster', () => { + const actual = isCloudUrlEnabled(); + expect(actual).toBe(true); + }); + + it('true when proxy connection is empty', () => { + const actual = isCloudUrlEnabled({ name: 'test', proxyAddress: '', serverName: '' }); + expect(actual).toBe(true); + }); + + it('true when proxy address is the same as server name and default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-proxy', + }); + expect(actual).toBe(true); + }); + it('false when proxy address is the same as server name but not default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:1234', + serverName: 'some-proxy', + }); + expect(actual).toBe(false); + }); + it('true when proxy address is not the same as server name', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-server-name', + }); + expect(actual).toBe(false); + }); + }); + describe('conversion from cloud url', () => { + it('empty url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection(''); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + + it('url with protocol and port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com:1234'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with protocol and no port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with no protocol to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + it('invalid url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('invalid%url'); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + }); + + describe('conversion to cloud url', () => { + it('empty proxy address to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: '', + serverName: 'test', + }); + expect(actual).toEqual(''); + }); + + it('empty server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: '', + }); + expect(actual).toEqual(''); + }); + + it('different proxy address and server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: 'another-test', + }); + expect(actual).toEqual(''); + }); + + it('valid proxy connection to cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test-proxy:9400', + serverName: 'test-proxy', + }); + expect(actual).toEqual('test-proxy'); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx new file mode 100644 index 0000000000000..1f4862f0113e7 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Cluster } from '../../../../../../common/lib'; +import { isAddressValid } from './validate_address'; + +export const i18nTexts = { + urlEmpty: ( + + ), + urlInvalid: ( + + ), +}; + +const CLOUD_DEFAULT_PROXY_PORT = '9400'; +const EMPTY_PROXY_VALUES = { proxyAddress: '', serverName: '' }; +const PROTOCOL_REGEX = new RegExp(/^https?:\/\//); + +export const isCloudUrlEnabled = (cluster?: Cluster): boolean => { + // enable cloud url for new clusters + if (!cluster) { + return true; + } + const { proxyAddress, serverName } = cluster; + if (!proxyAddress && !serverName) { + return true; + } + const portParts = (proxyAddress ?? '').split(':'); + const proxyAddressWithoutPort = portParts[0]; + const port = portParts[1]; + return port === CLOUD_DEFAULT_PROXY_PORT && proxyAddressWithoutPort === serverName; +}; + +const formatUrl = (url: string) => { + url = (url ?? '').trim().toLowerCase(); + // delete http(s):// protocol string if any + url = url.replace(PROTOCOL_REGEX, ''); + return url; +}; + +export const convertProxyConnectionToCloudUrl = (cluster?: Cluster): string => { + if (!isCloudUrlEnabled(cluster)) { + return ''; + } + return cluster?.serverName ?? ''; +}; +export const convertCloudUrlToProxyConnection = ( + cloudUrl: string = '' +): { proxyAddress: string; serverName: string } => { + cloudUrl = formatUrl(cloudUrl); + if (!cloudUrl || !isAddressValid(cloudUrl)) { + return EMPTY_PROXY_VALUES; + } + const address = cloudUrl.split(':')[0]; + return { proxyAddress: `${address}:${CLOUD_DEFAULT_PROXY_PORT}`, serverName: address }; +}; + +export const validateCloudUrl = (cloudUrl: string): JSX.Element | null => { + if (!cloudUrl) { + return i18nTexts.urlEmpty; + } + cloudUrl = formatUrl(cloudUrl); + if (!isAddressValid(cloudUrl)) { + return i18nTexts.urlInvalid; + } + return null; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx new file mode 100644 index 0000000000000..e0fa434f21d5c --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateName } from './validate_name'; +import { PROXY_MODE, SNIFF_MODE } from '../../../../../../common/constants'; +import { validateSeeds } from './validate_seeds'; +import { validateProxy } from './validate_proxy'; +import { validateServerName } from './validate_server_name'; +import { validateCloudUrl } from './validate_cloud_url'; +import { FormFields } from '../remote_cluster_form'; + +type ClusterError = JSX.Element | null; + +export interface ClusterErrors { + name?: ClusterError; + seeds?: ClusterError; + proxyAddress?: ClusterError; + serverName?: ClusterError; + cloudUrl?: ClusterError; +} +export const validateCluster = (fields: FormFields, isCloudEnabled: boolean): ClusterErrors => { + const { name, seeds = [], mode, proxyAddress, serverName, cloudUrlEnabled, cloudUrl } = fields; + + return { + name: validateName(name), + seeds: mode === SNIFF_MODE ? validateSeeds(seeds) : null, + proxyAddress: !cloudUrlEnabled && mode === PROXY_MODE ? validateProxy(proxyAddress) : null, + // server name is only required in cloud when proxy mode is enabled + serverName: + !cloudUrlEnabled && isCloudEnabled && mode === PROXY_MODE + ? validateServerName(serverName) + : null, + cloudUrl: cloudUrlEnabled ? validateCloudUrl(cloudUrl) : null, + }; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts deleted file mode 100644 index a5b3656b36de5..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -import { isAddressValid, isPortValid } from './validate_address'; - -export function validateSeed(seed?: string): string[] { - const errors: string[] = []; - - if (!seed) { - return errors; - } - - const isValid = isAddressValid(seed); - - if (!isValid) { - errors.push( - i18n.translate( - 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage', - { - defaultMessage: - 'Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. ' + - 'Hosts can only consist of letters, numbers, and dashes.', - } - ) - ); - } - - if (!isPortValid(seed)) { - errors.push( - i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', { - defaultMessage: 'A port is required.', - }) - ); - } - - return errors; -} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx new file mode 100644 index 0000000000000..4863dff5ec337 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { isAddressValid, isPortValid } from './validate_address'; + +export function validateSeed(seed?: string): JSX.Element[] { + const errors: JSX.Element[] = []; + + if (!seed) { + return errors; + } + + const isValid = isAddressValid(seed); + + if (!isValid) { + errors.push( + + ); + } + + if (!isPortValid(seed)) { + errors.push( + + ); + } + + return errors; +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts b/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts new file mode 100644 index 0000000000000..ab0f579c1a415 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ComponentType } from 'react'; + +export declare const RemoteClusterEdit: ComponentType; +export declare const RemoteClusterAdd: ComponentType; +export declare const RemoteClusterList: ComponentType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js index 6ee6bd6d87d58..124d2d42afb78 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js @@ -73,7 +73,7 @@ export class RemoteClusterAdd extends PureComponent { description={ } /> diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js index c68dd7ab10aa7..18ee2e2b3875d 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js @@ -27,10 +27,6 @@ import { getRouter, redirect } from '../../services'; import { setBreadcrumbs } from '../../services/breadcrumb'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; -const disabledFields = { - name: true, -}; - export class RemoteClusterEdit extends Component { static propTypes = { isLoading: PropTypes.bool, @@ -202,8 +198,7 @@ export class RemoteClusterEdit extends Component { ) : null} Store; diff --git a/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png b/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png new file mode 100644 index 0000000000000..f6c9302ef76ea Binary files /dev/null and b/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png differ diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index 107d4e127d1b5..540a8b40a6208 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -60,13 +60,14 @@ export class RemoteClustersUIPlugin initNotification(toasts, fatalErrors); initHttp(http); - const isCloudEnabled = Boolean(cloud?.isCloudEnabled); + const isCloudEnabled: boolean = Boolean(cloud?.isCloudEnabled); + const cloudBaseUrl: string = cloud?.baseUrl ?? ''; const { renderApp } = await import('./application'); const unmountAppCallback = await renderApp( element, i18nContext, - { isCloudEnabled }, + { isCloudEnabled, cloudBaseUrl }, history ); diff --git a/x-pack/plugins/remote_clusters/tsconfig.json b/x-pack/plugins/remote_clusters/tsconfig.json index 0bee6300cf0b2..9dc7926bd62ea 100644 --- a/x-pack/plugins/remote_clusters/tsconfig.json +++ b/x-pack/plugins/remote_clusters/tsconfig.json @@ -8,10 +8,12 @@ "declarationMap": true }, "include": [ + "__jest__/**/*", "common/**/*", "fixtures/**/*", "public/**/*", "server/**/*", + "../../../typings/**/*", ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index c99cabb50e3dc..40a202f5257a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -341,7 +341,7 @@ describe('AddToCaseAction', () => { ); expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('disabled') + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') ).toBeTruthy(); }); @@ -358,7 +358,7 @@ describe('AddToCaseAction', () => { ); expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('disabled') + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index decd37a7646e7..45c1355cecfa7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -172,7 +172,7 @@ const AddToCaseActionComponent: React.FC = ({ size="s" iconType="folderClosed" onClick={openPopover} - disabled={isDisabled} + isDisabled={isDisabled} /> ), diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index b2e5638ff120e..26b9662a8f19b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -215,19 +215,20 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const openAlertActionComponent = ( - - {i18n.ACTION_OPEN_ALERT} - - ); + const openAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_OPEN_ALERT} + + ); + }, [openAlertActionOnClick, hasIndexUpdateDelete, hasIndexMaintenance]); const closeAlertActionClick = useCallback(() => { updateAlertStatusAction({ @@ -248,19 +249,20 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const closeAlertActionComponent = ( - - {i18n.ACTION_CLOSE_ALERT} - - ); + const closeAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_CLOSE_ALERT} + + ); + }, [closeAlertActionClick, hasIndexUpdateDelete, hasIndexMaintenance]); const inProgressAlertActionClick = useCallback(() => { updateAlertStatusAction({ @@ -281,72 +283,77 @@ const AlertContextMenuComponent: React.FC = ({ setEventsLoading, ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const inProgressAlertActionComponent = ( - - {i18n.ACTION_IN_PROGRESS_ALERT} - - ); - - const button = ( - - - - ); + const inProgressAlertActionComponent = useMemo(() => { + return ( + + {i18n.ACTION_IN_PROGRESS_ALERT} + + ); + }, [canUserCRUD, hasIndexUpdateDelete, inProgressAlertActionClick]); + + const button = useMemo(() => { + return ( + + + + ); + }, [disabled, onButtonClick, ariaLabel]); const handleAddEndpointExceptionClick = useCallback((): void => { closePopover(); setOpenAddExceptionModal('endpoint'); }, [closePopover]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const addEndpointExceptionComponent = ( - - {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} - - ); + const addEndpointExceptionComponent = useMemo(() => { + return ( + + {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} + + ); + }, [canUserCRUD, hasIndexWrite, isEndpointAlert, handleAddEndpointExceptionClick]); const handleAddExceptionClick = useCallback((): void => { closePopover(); setOpenAddExceptionModal('detection'); }, [closePopover]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const addExceptionComponent = ( - - - {i18n.ACTION_ADD_EXCEPTION} - - - ); + const addExceptionComponent = useMemo(() => { + return ( + + + {i18n.ACTION_ADD_EXCEPTION} + + + ); + }, [handleAddExceptionClick, canUserCRUD, hasIndexWrite]); const statusFilters = useMemo(() => { if (!alertStatus) { diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap index 8db9006da6156..1b91d396bee30 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/tooltip_footer.test.tsx.snap @@ -27,16 +27,16 @@ exports[`ToolTipFilter renders correctly against snapshot 1`] = ` aria-label="Next" color="text" data-test-subj="previous-feature-button" - disabled={true} iconType="arrowLeft" + isDisabled={true} onClick={[MockFunction]} /> diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx index a74022a222528..cc7662cf1e960 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.test.tsx @@ -42,7 +42,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(true); }); @@ -70,9 +70,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - false - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(false); }); test('nextFeature is called when featureIndex is < totalFeatures', () => { @@ -102,7 +102,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(false); }); @@ -130,9 +130,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - true - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(true); }); test('nextFunction is not called when featureIndex >== totalFeatures', () => { @@ -161,7 +161,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(true); }); @@ -189,9 +189,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - true - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(true); }); test('nextFunction is not called when only a single feature is provided', () => { @@ -221,7 +221,7 @@ describe('ToolTipFilter', () => { ); expect( - wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('disabled') + wrapper.find('[data-test-subj="previous-feature-button"]').first().prop('isDisabled') ).toBe(false); }); @@ -249,9 +249,9 @@ describe('ToolTipFilter', () => { /> ); - expect(wrapper.find('[data-test-subj="next-feature-button"]').first().prop('disabled')).toBe( - false - ); + expect( + wrapper.find('[data-test-subj="next-feature-button"]').first().prop('isDisabled') + ).toBe(false); }); test('nextFunction is called when featureIndex > 0 && featureIndex < totalFeatures', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx index 252260b2c5a2b..dbb280228e504 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/tooltip_footer.tsx @@ -54,7 +54,7 @@ export const ToolTipFooterComponent = ({ onClick={previousFeature} iconType="arrowLeft" aria-label="Next" - disabled={featureIndex <= 0} + isDisabled={featureIndex <= 0} /> = totalFeatures - 1} + isDisabled={featureIndex >= totalFeatures - 1} /> diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index c5cbbeb09ed6d..ef7236084508d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -27,12 +27,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index dd636d5a180d9..d6693dc1f7a0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -29,12 +29,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 0a265adf620ee..b0b4232651803 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -34,7 +34,7 @@ describe('import_rules_route', () => { let server: ReturnType; let request: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); @@ -42,7 +42,7 @@ describe('import_rules_route', () => { config = createMockConfig(); const hapiStream = buildHapiStream(ruleIdsToNdJsonString(['rule-1'])); request = getImportRulesRequest(hapiStream); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 88250fb920d6c..93fdf9c5f8194 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -24,12 +24,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('patch_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 1f21a11f22ef5..6e62f65f44858 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -26,12 +26,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('patch_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 09ac156c375ee..41b31b04e3424 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -26,12 +26,12 @@ jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('update_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); clients.alertsClient.update.mockResolvedValue(getResult()); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index e5bea42bc49a1..c80d32e09ccab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -28,12 +28,12 @@ jest.mock('../../rules/update_rules_notifications'); describe('update_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let ml: ReturnType; + let ml: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - ml = mlServicesMock.create(); + ml = mlServicesMock.createSetupContract(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts index b41ba543675ec..d87c53ecfba71 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.test.ts @@ -16,7 +16,7 @@ jest.mock('../../../common/machine_learning/has_ml_admin_permissions'); describe('isMlAdmin', () => { it('returns true if hasMlAdminPermissions is true', async () => { - const mockMl = mlServicesMock.create(); + const mockMl = mlServicesMock.createSetupContract(); const request = httpServerMock.createKibanaRequest(); const savedObjectsClient = savedObjectsClientMock.create(); (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); @@ -25,7 +25,7 @@ describe('isMlAdmin', () => { }); it('returns false if hasMlAdminPermissions is false', async () => { - const mockMl = mlServicesMock.create(); + const mockMl = mlServicesMock.createSetupContract(); const request = httpServerMock.createKibanaRequest(); const savedObjectsClient = savedObjectsClientMock.create(); (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); @@ -56,13 +56,13 @@ describe('hasMlLicense', () => { describe('mlAuthz', () => { let licenseMock: ReturnType; - let mlMock: ReturnType; + let mlMock: ReturnType; let request: KibanaRequest; let savedObjectsClient: SavedObjectsClientContract; beforeEach(() => { licenseMock = licensingMock.createLicenseMock(); - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); request = httpServerMock.createKibanaRequest(); savedObjectsClient = savedObjectsClientMock.create(); }); diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts index 5d1b090e98a79..a121a682d2892 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts @@ -5,25 +5,9 @@ * 2.0. */ -import { MlPluginSetup } from '../../../../ml/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; +import { mlPluginServerMock } from '../../../../ml/server/mocks'; -const createMockClient = () => elasticsearchServiceMock.createLegacyClusterClient(); -const createMockMlSystemProvider = () => - jest.fn(() => ({ - mlCapabilities: jest.fn(), - })); - -export const mlServicesMock = { - create: () => - (({ - modulesProvider: jest.fn(), - jobServiceProvider: jest.fn(), - anomalyDetectorsProvider: jest.fn(), - mlSystemProvider: createMockMlSystemProvider(), - mlClient: createMockClient(), - } as unknown) as jest.Mocked), -}; +export const mlServicesMock = mlPluginServerMock; const mockValidateRuleType = jest.fn().mockResolvedValue({ valid: true, message: undefined }); const createBuildMlAuthzMock = () => diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index b53f90f40f621..64a33068ad686 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -21,12 +21,12 @@ import { fetchDetectionsUsage, fetchDetectionsMetrics } from './index'; describe('Detections Usage and Metrics', () => { let esClientMock: jest.Mocked; let savedObjectsClientMock: jest.Mocked; - let mlMock: ReturnType; + let mlMock: ReturnType; describe('fetchDetectionsUsage()', () => { beforeEach(() => { esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); }); it('returns zeroed counts if both calls are empty', async () => { @@ -108,7 +108,7 @@ describe('Detections Usage and Metrics', () => { describe('fetchDetectionsMetrics()', () => { beforeEach(() => { - mlMock = mlServicesMock.create(); + mlMock = mlServicesMock.createSetupContract(); }); it('returns an empty array if there is no data', async () => { diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 9f91398cc7d24..e168ec21438c0 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -15,7 +15,7 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export const BUILT_IN_ALERTS_FEATURE = { id: STACK_ALERTS_FEATURE_ID, name: i18n.translate('xpack.stackAlerts.featureRegistry.actionsFeatureName', { - defaultMessage: 'Stack Alerts', + defaultMessage: 'Stack Rules', }), app: [], category: DEFAULT_APP_CATEGORIES.management, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 277226c81c925..7965db99b335b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -91,7 +91,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = defaultMessage: 'Runtime mappings', })} > - + {runtimeMappings !== undefined && Object.keys(runtimeMappings).length > 0 ? ( = React.memo((props) => { } > <> - + {/* Flex Column #1: Search Bar / Advanced Search Editor */} {searchItems.savedSearch === undefined && ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0c16860acf56c..14283aefd6149 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13725,7 +13725,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 +13751,6 @@ "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "説明", "xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "ダッシュボード「{dashboardTitle}」は正常に更新されました", "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "タイトル", - "xpack.ml.explorer.dashboardsTitle": "スイムレーンをダッシュボードに追加", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "異常スコア", "xpack.ml.explorer.distributionChart.entityLabel": "エンティティ", "xpack.ml.explorer.distributionChart.typicalLabel": "通常", @@ -16761,10 +16761,6 @@ "xpack.remoteClusters.addTitle": "リモートクラスターを追加", "xpack.remoteClusters.appName": "リモートクラスター", "xpack.remoteClusters.appTitle": "リモートクラスター", - "xpack.remoteClusters.cloudClusterInformationDescription": "クラスターのプロキシアドレスとサーバー名を見つけるには、デプロイメニューの{security}ページに移動し、{searchString}を検索します。", - "xpack.remoteClusters.cloudClusterInformationTitle": "Elasticsearch Cloudデプロイのプロキシモードを使用", - "xpack.remoteClusters.cloudClusterSearchDescription": "リモートクラスターパラメーター", - "xpack.remoteClusters.cloudClusterSecurityDescription": "セキュリティ", "xpack.remoteClusters.configuredByNodeWarningTitle": "このリモートクラスターはノードの elasticsearch.yml 構成ファイルで定義されているため、編集または削除できません。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "接続済み", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未接続", @@ -16838,7 +16834,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "サーバー名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "リモートクラスターごとに開くソケット接続の数。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "リクエストを非表示", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "「シードノード」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "「名前」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "「プロキシアドレス」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "「シードノード」フィールドが無効です。", @@ -22903,7 +22898,6 @@ "xpack.uptime.certs.status.ok.label": " {okRelativeDate}", "xpack.uptime.charts.mlAnnotation.header": "スコア:{score}", "xpack.uptime.charts.mlAnnotation.severity": "深刻度:{severity}", - "xpack.uptime.components.embeddables.embeddedMap.embeddablePanelTitle": "オブザーバー位置情報マップを監視", "xpack.uptime.controls.selectSeverity.criticalLabel": "致命的", "xpack.uptime.controls.selectSeverity.majorLabel": "メジャー", "xpack.uptime.controls.selectSeverity.minorLabel": "マイナー", @@ -22933,12 +22927,7 @@ "xpack.uptime.filterPopout.searchMessage.ariaLabel": "{title} を検索", "xpack.uptime.filterPopover.filterItem.label": "{title} {item}でフィルタリングします。", "xpack.uptime.integrationLink.missingDataMessage": "この統合に必要なデータが見つかりませんでした。", - "xpack.uptime.locationAvailabilityViewToggleLegend": "トグルを表示", - "xpack.uptime.locationMap.locations.missing.message": "重要な位置情報構成がありません。{codeBlock}フィールドを使用して、アップタイムチェック用に一意の地域を作成できます。", - "xpack.uptime.locationMap.locations.missing.message1": "詳細については、ドキュメンテーションを参照してください。", - "xpack.uptime.locationMap.locations.missing.title": "地理情報の欠測", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", - "xpack.uptime.mapToolTip.AvailabilityStat.title": "{value} %", "xpack.uptime.ml.durationChart.exploreInMlApp": "ML アプリで探索", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "異常検知", "xpack.uptime.ml.enableAnomalyDetectionPanel.cancelLabel": "キャンセル", @@ -23590,4 +23579,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5e5f53356a2e8..50a3aeb5e0c44 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13905,7 +13905,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 +13931,6 @@ "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "描述", "xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "仪表板“{dashboardTitle}”已成功更新", "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "标题", - "xpack.ml.explorer.dashboardsTitle": "将泳道添加到仪表板", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "异常分数", "xpack.ml.explorer.distributionChart.entityLabel": "实体", "xpack.ml.explorer.distributionChart.typicalLabel": "典型", @@ -16987,10 +16987,6 @@ "xpack.remoteClusters.addTitle": "添加远程集群", "xpack.remoteClusters.appName": "远程集群", "xpack.remoteClusters.appTitle": "远程集群", - "xpack.remoteClusters.cloudClusterInformationDescription": "要查找您的集群的代理地址和服务器名称,请前往部署菜单的{security}页面并搜索“{searchString}”。", - "xpack.remoteClusters.cloudClusterInformationTitle": "将代理模式用于 Elastic Cloud 部署", - "xpack.remoteClusters.cloudClusterSearchDescription": "远程集群参数", - "xpack.remoteClusters.cloudClusterSecurityDescription": "安全", "xpack.remoteClusters.configuredByNodeWarningTitle": "您无法编辑或删除此远程集群,因为它是在节点的 elasticsearch.yml 配置文件中定义的。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "已连接", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未连接", @@ -17065,7 +17061,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "服务器名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "每个远程集群要打开的套接字数目。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "隐藏请求", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "“种子节点”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "“名称”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "“代理地址”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "“种子节点”字段无效。", @@ -23262,7 +23257,6 @@ "xpack.uptime.certs.status.ok.label": " 对于 {okRelativeDate}", "xpack.uptime.charts.mlAnnotation.header": "分数:{score}", "xpack.uptime.charts.mlAnnotation.severity": "严重性:{severity}", - "xpack.uptime.components.embeddables.embeddedMap.embeddablePanelTitle": "监测观察者位置地图", "xpack.uptime.controls.selectSeverity.criticalLabel": "紧急", "xpack.uptime.controls.selectSeverity.majorLabel": "重大", "xpack.uptime.controls.selectSeverity.minorLabel": "轻微", @@ -23292,12 +23286,7 @@ "xpack.uptime.filterPopout.searchMessage.ariaLabel": "搜索 {title}", "xpack.uptime.filterPopover.filterItem.label": "按 {title} {item} 筛选。", "xpack.uptime.integrationLink.missingDataMessage": "未找到此集成的所需数据。", - "xpack.uptime.locationAvailabilityViewToggleLegend": "视图切换", - "xpack.uptime.locationMap.locations.missing.message": "重要的地理位置配置缺失。您可以使用 {codeBlock} 字段为您的运行时间检查创建独特的地理区域。", - "xpack.uptime.locationMap.locations.missing.message1": "在我们的文档中获取更多的信息。", - "xpack.uptime.locationMap.locations.missing.title": "地理信息缺失", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", - "xpack.uptime.mapToolTip.AvailabilityStat.title": "{value} %", "xpack.uptime.ml.durationChart.exploreInMlApp": "在 ML 应用中浏览", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "异常检测", "xpack.uptime.ml.enableAnomalyDetectionPanel.cancelLabel": "取消", @@ -23959,4 +23948,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 426d3f1f10db8..4ba836c1e5d26 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -1,8 +1,16 @@ { - "configPath": ["xpack", "uptime"], + "configPath": [ + "xpack", + "uptime" + ], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["data", "home", "observability", "ml"], + "optionalPlugins": [ + "data", + "home", + "observability", + "ml" + ], "requiredPlugins": [ "alerting", "embeddable", @@ -14,5 +22,12 @@ "server": true, "ui": true, "version": "8.0.0", - "requiredBundles": ["observability", "kibanaReact", "kibanaUtils", "home", "data", "ml", "maps"] -} + "requiredBundles": [ + "observability", + "kibanaReact", + "kibanaUtils", + "home", + "data", + "ml" + ] +} \ No newline at end of file diff --git a/x-pack/plugins/uptime/public/components/monitor/index.ts b/x-pack/plugins/uptime/public/components/monitor/index.ts index 73ac77a61461f..2c95ac3347723 100644 --- a/x-pack/plugins/uptime/public/components/monitor/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/index.ts @@ -7,7 +7,6 @@ export * from './ml'; export * from './ping_list'; -export * from './status_details/location_map'; export * from './status_details'; export * from './ping_histogram'; export * from './monitor_charts'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap deleted file mode 100644 index 94cbeb49a32cf..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__snapshots__/location_availability.test.tsx.snap +++ /dev/null @@ -1,234 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationAvailability component doesnt shows warning if geo is provided 1`] = ` - - - - - - - - - - - - - -`; - -exports[`LocationAvailability component renders correctly against snapshot 1`] = ` - - - - -

- Monitoring from -

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

{MonitoringFrom}

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

{MonitoringFrom}

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