diff --git a/.ci/packer_cache.sh b/.ci/packer_cache.sh index e4b5e35e1e4a9..5317b2c500b49 100755 --- a/.ci/packer_cache.sh +++ b/.ci/packer_cache.sh @@ -2,5 +2,8 @@ set -e +# cache image used by kibana-load-testing project +docker pull "maven:3.6.3-openjdk-8-slim" + ./.ci/packer_cache_for_branch.sh master ./.ci/packer_cache_for_branch.sh 7.x diff --git a/.eslintrc.js b/.eslintrc.js index b70090a50e64d..ab868c29b7bed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -89,6 +89,72 @@ const SAFER_LODASH_SET_DEFINITELYTYPED_HEADER = ` */ `; +/** Packages which should not be included within production code. */ +const DEV_PACKAGES = [ + 'kbn-babel-code-parser', + 'kbn-dev-utils', + 'kbn-docs-utils', + 'kbn-es*', + 'kbn-eslint*', + 'kbn-optimizer', + 'kbn-plugin-generator', + 'kbn-plugin-helpers', + 'kbn-pm', + 'kbn-storybook', + 'kbn-telemetry-tools', + 'kbn-test', +]; + +/** Directories (at any depth) which include dev-only code. */ +const DEV_DIRECTORIES = [ + '.storybook', + '__tests__', + '__test__', + '__jest__', + '__fixtures__', + '__mocks__', + '__stories__', + 'e2e', + 'fixtures', + 'ftr_e2e', + 'integration_tests', + 'manual_tests', + 'mock', + 'storybook', + 'scripts', + 'test', + 'test-d', + 'test_utils', + 'test_utilities', + 'test_helpers', + 'tests_client_integration', +]; + +/** File patterns for dev-only code. */ +const DEV_FILE_PATTERNS = [ + '*.mock.{js,ts,tsx}', + '*.test.{js,ts,tsx}', + '*.test.helpers.{js,ts,tsx}', + '*.stories.{js,ts,tsx}', + '*.story.{js,ts,tsx}', + '*.stub.{js,ts,tsx}', + 'mock.{js,ts,tsx}', + '_stubs.{js,ts,tsx}', + '{testHelpers,test_helper,test_utils}.{js,ts,tsx}', + '{postcss,webpack}.config.js', +]; + +/** Glob patterns which describe dev-only code. */ +const DEV_PATTERNS = [ + ...DEV_PACKAGES.map((pkg) => `packages/${pkg}/**/*`), + ...DEV_DIRECTORIES.map((dir) => `{packages,src,x-pack}/**/${dir}/**/*`), + ...DEV_FILE_PATTERNS.map((file) => `{packages,src,x-pack}/**/${file}`), + 'packages/kbn-interpreter/tasks/**/*', + 'src/dev/**/*', + 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*', + 'x-pack/plugins/*/server/scripts/**/*', +]; + module.exports = { root: true, @@ -491,43 +557,17 @@ module.exports = { }, /** - * Files that ARE NOT allowed to use devDependencies - */ - { - files: ['x-pack/**/*.js', 'packages/kbn-interpreter/**/*.js'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: false, - peerDependencies: true, - packageDir: '.', - }, - ], - }, - }, - - /** - * Files that ARE allowed to use devDependencies + * Single package.json rules, it tells eslint to ignore the child package.json files + * and look for dependencies declarations in the single and root level package.json */ { - files: [ - 'packages/kbn-es/src/**/*.js', - 'packages/kbn-interpreter/tasks/**/*.js', - 'packages/kbn-interpreter/src/plugin/**/*.js', - 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*.js', - 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__,public}/**/*.js', - 'x-pack/**/*.test.js', - 'x-pack/test_utils/**/*', - 'x-pack/gulpfile.js', - 'x-pack/plugins/apm/public/utils/testHelpers.js', - 'x-pack/plugins/canvas/shareable_runtime/postcss.config.js', - ], + files: ['{src,x-pack,packages}/**/*.{js,mjs,ts,tsx}'], rules: { 'import/no-extraneous-dependencies': [ 'error', { - devDependencies: true, + /* Files that ARE allowed to use devDependencies */ + devDependencies: [...DEV_PATTERNS], peerDependencies: true, packageDir: '.', }, @@ -1420,21 +1460,5 @@ module.exports = { ], }, }, - - /** - * Single package.json rules, it tells eslint to ignore the child package.json files - * and look for dependencies declarations in the single and root level package.json - */ - { - files: ['**/*.{js,mjs,ts,tsx}'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - packageDir: '.', - }, - ], - }, - }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f2f260addb35..33b3e4a7dede6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,6 +59,7 @@ /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services /x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-services /x-pack/plugins/runtime_fields @elastic/kibana-app-services +/x-pack/test/search_sessions_integration/ @elastic/kibana-app-services #CC# /src/plugins/bfetch/ @elastic/kibana-app-services #CC# /src/plugins/index_pattern_management/ @elastic/kibana-app-services #CC# /src/plugins/inspector/ @elastic/kibana-app-services diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md deleted file mode 100644 index 38fcb7af30b47..0000000000000 --- a/.github/ISSUE_TEMPLATE/Question.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Question -about: Who, what, when, where, and how? - ---- - -Hey, stop right there! - -We use GitHub to track feature requests and bug reports. Please do not submit issues for questions about how to use features of Kibana, how to set Kibana up, best practices, or development related help. - -However, we do want to help! Head on over to our official Kibana forums and ask your questions there. In additional to awesome, knowledgeable community contributors, core Kibana developers are on the forums every single day to help you out. - -The forums are here: https://discuss.elastic.co/c/kibana - -We can't stop you from opening an issue here, but it will likely linger without a response for days or weeks before it is closed and we ask you to join us on the forums instead. Save yourself the time, and ask on the forums today. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..348d756c141b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question + url: https://discuss.elastic.co/c/kibana + about: Please ask and answer questions here. diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 9f0e6e0231feb..4639414b4564e 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.tar.gz"], + sha256 = "dd7ea7efda7655c218ca707f55c3e1b9c68055a70c31a98f264b3445bc8f4cb1", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.3/rules_nodejs-3.2.3.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.2.2") +check_rules_nodejs_version(minimum_version_string = "3.2.3") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/dev_docs/assets/api_doc_pick.png b/dev_docs/assets/api_doc_pick.png new file mode 100644 index 0000000000000..825fa47b266cb Binary files /dev/null and b/dev_docs/assets/api_doc_pick.png differ diff --git a/dev_docs/assets/dev_docs_nested_object.png b/dev_docs/assets/dev_docs_nested_object.png new file mode 100644 index 0000000000000..a6b2f533b3858 Binary files /dev/null and b/dev_docs/assets/dev_docs_nested_object.png differ diff --git a/dev_docs/best_practices.mdx b/dev_docs/best_practices.mdx index 6156c05197289..4d51263f93372 100644 --- a/dev_docs/best_practices.mdx +++ b/dev_docs/best_practices.mdx @@ -12,6 +12,132 @@ tags: ['kibana', 'onboarding', 'dev', 'architecture'] First things first, be sure to review our and check out all the available platform that can simplify plugin development. +## Developer documentation + +### High-level documentation + +#### Structure + +Refer to [divio documentation](https://documentation.divio.com/) for guidance on where and how to structure our high-level documentation. + + and + sections are both _explanation_ oriented, + covers both _tutorials_ and _How to_, and +the section covers _reference_ material. + +#### Location + +If the information spans multiple plugins, consider adding it to the [dev_docs](https://github.com/elastic/kibana/tree/master/dev_docs) folder. If it is plugin specific, consider adding it inside the plugin folder. Write it in an mdx file if you would like it to show up in our new (beta) documentation system. + + + +To add docs into the new docs system, create an `.mdx` file that +contains . Read about the syntax . An extra step is needed to add a menu item. will walk you through how to set the docs system +up locally and edit the nav menu. + + + +#### Keep content fresh + +A fresh pair of eyes are invaluable. Recruit new hires to read, review and update documentation. Leads should also periodically review documentation to ensure it stays up to date. File issues any time you notice documentation is outdated. + +#### Consider your target audience + +Documentation in the Kibana Developer Guide is targeted towards developers building Kibana plugins. Keep implementation details about internal plugin code out of these docs. + +#### High to low level + +When a developer first lands in our docs, think about their journey. Introduce basic concepts before diving into details. The left navigation should be set up so documents on top are higher level than documents near the bottom. + +#### Think outside-in + +It's easy to forget what it felt like to first write code in Kibana, but do your best to frame these docs "outside-in". Don't use esoteric, internal language unless a definition is documented and linked. The fresh eyes of a new hire can be a great asset. + +### API documentation + +We automatically generate . The following guidelines will help ensure your are useful. + +#### Code comments + +Every publicly exposed function, class, interface, type, parameter and property should have a comment using JSDoc style comments. + +- Use `@param` tags for every function parameter. +- Use `@returns` tags for return types. +- Use `@throws` when appropriate. +- Use `@beta` or `@deprecated` when appropriate. +- Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. + +#### Interfaces vs inlined types + +Prefer types and interfaces over complex inline objects. For example, prefer: + +```ts +/** +* The SearchSpec interface contains settings for creating a new SearchService, like +* username and password. +*/ +export interface SearchSpec { + /** + * Stores the username. Duh, + */ + username: string; + /** + * Stores the password. I hope it's encrypted! + */ + password: string; +} + + /** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: SearchSpec) => string; +``` + +over: + +```ts +/** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: { username: string; password: string }) => string; +``` + +In the former, there will be a link to the `SearchSpec` interface with documentation for the `username` and `password` properties. In the latter the object will render inline, without comments: + +![prefer interfaces documentation](./assets/dev_docs_nested_object.png) + +#### Export every type used in a public API + +When a publicly exported API items references a private type, this results in a broken link in our docs system. The private type is, by proxy, part of your public API, and as such, should be exported. + +Do: + +```ts +export interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +Don't: + +```ts +interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +#### Avoid “Pick” + +`Pick` not only ends up being unhelpful in our documentation system, but it's also of limited help in your IDE. For that reason, avoid `Pick` and other similarly complex types on your public API items. Using these semantics internally is fine. + +![pick api documentation](./assets/api_doc_pick.png) + +### Example plugins + +Running Kibana with `yarn start --run-examples` will include all [example plugins](https://github.com/elastic/kibana/tree/master/examples). These are tested examples of platform services in use. We strongly encourage anyone providing a platform level service or to include a tutorial that links to a tested example plugin. This is better than relying on copied code snippets, which can quickly get out of date. + ## Performance Build with scalability in mind. diff --git a/docs/api/actions-and-connectors.asciidoc b/docs/api/actions-and-connectors.asciidoc index 5480cdd57f691..ff4cb8401091e 100644 --- a/docs/api/actions-and-connectors.asciidoc +++ b/docs/api/actions-and-connectors.asciidoc @@ -5,19 +5,19 @@ Manage Actions and Connectors. The following connector APIs are available: -* <> to retrieve a single connector by ID +* <> to retrieve a single connector by ID -* <> to retrieve all connectors +* <> to retrieve all connectors -* <> to retrieve a list of all connector types +* <> to retrieve a list of all connector types -* <> to create connectors +* <> to create connectors -* <> to update the attributes for an existing connector +* <> to update the attributes for an existing connector -* <> to execute a connector by ID +* <> to execute a connector by ID -* <> to delete a connector by ID +* <> to delete a connector by ID For deprecated APIs, refer to <>. diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index c9a09e890ea6d..554e84615d568 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -1,25 +1,25 @@ -[[actions-and-connectors-api-create]] +[[create-connector-api]] === Create connector API ++++ -Create connector API +Create connector ++++ Creates a connector. -[[actions-and-connectors-api-create-request]] +[[create-connector-api-request]] ==== Request `POST :/api/actions/connector` `POST :/s//api/actions/connector` -[[actions-and-connectors-api-create-path-params]] +[[create-connector-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-create-request-body]] +[[create-connector-api-request-body]] ==== Request body `name`:: @@ -36,15 +36,15 @@ Creates a connector. (Required, object) The secrets configuration for the connector. Secrets configuration properties vary depending on the connector type. For information about the secrets configuration properties, refer to <>. + -WARNING: Remember these values. You must provide them each time you call the <> API. +WARNING: Remember these values. You must provide them each time you call the <> API. -[[actions-and-connectors-api-create-request-codes]] +[[create-connector-api-request-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-create-example]] +[[create-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/delete.asciidoc b/docs/api/actions-and-connectors/delete.asciidoc index a9f9e658613e0..021a3f7cdf3f7 100644 --- a/docs/api/actions-and-connectors/delete.asciidoc +++ b/docs/api/actions-and-connectors/delete.asciidoc @@ -1,21 +1,21 @@ -[[actions-and-connectors-api-delete]] +[[delete-connector-api]] === Delete connector API ++++ -Delete connector API +Delete connector ++++ Deletes an connector by ID. WARNING: When you delete a connector, _it cannot be recovered_. -[[actions-and-connectors-api-delete-request]] +[[delete-connector-api-request]] ==== Request `DELETE :/api/actions/connector/` `DELETE :/s//api/actions/connector/` -[[actions-and-connectors-api-delete-path-params]] +[[delete-connector-api-path-params]] ==== Path parameters `id`:: @@ -24,7 +24,7 @@ WARNING: When you delete a connector, _it cannot be recovered_. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-delete-response-codes]] +[[delete-connector-api-response-codes]] ==== Response code `200`:: diff --git a/docs/api/actions-and-connectors/execute.asciidoc b/docs/api/actions-and-connectors/execute.asciidoc index b87380907f7bb..e830c9b4bbf88 100644 --- a/docs/api/actions-and-connectors/execute.asciidoc +++ b/docs/api/actions-and-connectors/execute.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-execute]] +[[execute-connector-api]] === Execute connector API ++++ -Execute connector API +Execute connector ++++ Executes a connector by ID. -[[actions-and-connectors-api-execute-request]] +[[execute-connector-api-request]] ==== Request `POST :/api/actions/connector//_execute` `POST :/s//api/actions/connector//_execute` -[[actions-and-connectors-api-execute-params]] +[[execute-connector-api-params]] ==== Path parameters `id`:: @@ -22,20 +22,20 @@ Executes a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-execute-request-body]] +[[execute-connector-api-request-body]] ==== Request body `params`:: (Required, object) The parameters of the connector. Parameter properties vary depending on the connector type. For information about the parameter properties, refer to <>. -[[actions-and-connectors-api-execute-codes]] +[[execute-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-execute-example]] +[[execute-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index 33d37a4add4dd..0d9af45c4ef0c 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-get]] +[[get-connector-api]] === Get connector API ++++ -Get connector API +Get connector ++++ Retrieves a connector by ID. -[[actions-and-connectors-api-get-request]] +[[get-connector-api-request]] ==== Request `GET :/api/actions/connector/` `GET :/s//api/actions/connector/` -[[actions-and-connectors-api-get-params]] +[[get-connector-api-params]] ==== Path parameters `id`:: @@ -22,13 +22,13 @@ Retrieves a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-codes]] +[[get-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-example]] +[[get-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index 8b4977d61e741..e4e67a9bbde73 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-get-all]] -=== Get all actions API +[[get-all-connectors-api]] +=== Get all connectors API ++++ -Get all actions API +Get all connectors ++++ Retrieves all connectors. -[[actions-and-connectors-api-get-all-request]] +[[get-all-connectors-api-request]] ==== Request `GET :/api/actions/connectors` `GET :/s//api/actions/connectors` -[[actions-and-connectors-api-get-all-path-params]] +[[get-all-connectors-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-all-codes]] +[[get-all-connectors-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-all-example]] +[[get-all-connectors-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/legacy/create.asciidoc b/docs/api/actions-and-connectors/legacy/create.asciidoc index faf6227f01947..af4feddcb80fb 100644 --- a/docs/api/actions-and-connectors/legacy/create.asciidoc +++ b/docs/api/actions-and-connectors/legacy/create.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-create]] ==== Legacy Create connector API ++++ -Legacy Create connector API +Legacy Create connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Creates a connector. diff --git a/docs/api/actions-and-connectors/legacy/delete.asciidoc b/docs/api/actions-and-connectors/legacy/delete.asciidoc index b02f1011fd9b4..170fceba2d157 100644 --- a/docs/api/actions-and-connectors/legacy/delete.asciidoc +++ b/docs/api/actions-and-connectors/legacy/delete.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-delete]] ==== Legacy Delete connector API ++++ -Legacy Delete connector API +Legacy Delete connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Deletes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/execute.asciidoc b/docs/api/actions-and-connectors/legacy/execute.asciidoc index 30cb18c54aa69..200844ab72f17 100644 --- a/docs/api/actions-and-connectors/legacy/execute.asciidoc +++ b/docs/api/actions-and-connectors/legacy/execute.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-execute]] ==== Legacy Execute connector API ++++ -Legacy Execute connector API +Legacy Execute connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Executes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get.asciidoc b/docs/api/actions-and-connectors/legacy/get.asciidoc index cf8cc1b6b677e..1b138fb7032e0 100644 --- a/docs/api/actions-and-connectors/legacy/get.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get]] ==== Legacy Get connector API ++++ -Legacy Get connector API +Legacy Get connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get_all.asciidoc b/docs/api/actions-and-connectors/legacy/get_all.asciidoc index 24ad446d95d95..ba235955c005e 100644 --- a/docs/api/actions-and-connectors/legacy/get_all.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get_all.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get-all]] ==== Legacy Get all connector API ++++ -Legacy Get all connector API +Legacy Get all connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves all connectors. diff --git a/docs/api/actions-and-connectors/legacy/list.asciidoc b/docs/api/actions-and-connectors/legacy/list.asciidoc index 86026f332d917..8acfd5415af57 100644 --- a/docs/api/actions-and-connectors/legacy/list.asciidoc +++ b/docs/api/actions-and-connectors/legacy/list.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-list]] ==== Legacy List connector types API ++++ -Legacy List all connector types API +Legacy List all connector types ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a list of all connector types. diff --git a/docs/api/actions-and-connectors/legacy/update.asciidoc b/docs/api/actions-and-connectors/legacy/update.asciidoc index c2e841988717a..517daf9a40dca 100644 --- a/docs/api/actions-and-connectors/legacy/update.asciidoc +++ b/docs/api/actions-and-connectors/legacy/update.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-update]] ==== Legacy Update connector API ++++ -Legacy Update connector API +Legacy Update connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Updates the attributes for an existing connector. diff --git a/docs/api/actions-and-connectors/list.asciidoc b/docs/api/actions-and-connectors/list.asciidoc index 941f7b4376e91..bd1ccb777b9ae 100644 --- a/docs/api/actions-and-connectors/list.asciidoc +++ b/docs/api/actions-and-connectors/list.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-list]] +[[list-connector-types-api]] === List connector types API ++++ -List all connector types API +List all connector types ++++ Retrieves a list of all connector types. -[[actions-and-connectors-api-list-request]] +[[list-connector-types-api-request]] ==== Request `GET :/api/actions/connector_types` `GET :/s//api/actions/connector_types` -[[actions-and-connectors-api-list-path-params]] +[[list-connector-types-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-list-codes]] +[[list-connector-types-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-list-example]] +[[list-connector-types-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc index 6c4e6040bdfb5..f522cb8d048e0 100644 --- a/docs/api/actions-and-connectors/update.asciidoc +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-update]] +[[update-connector-api]] === Update connector API ++++ -Update connector API +Update connector ++++ Updates the attributes for an existing connector. -[[actions-and-connectors-api-update-request]] +[[update-connector-api-request]] ==== Request `PUT :/api/actions/connector/` `PUT :/s//api/actions/connector/` -[[actions-and-connectors-api-update-params]] +[[update-connector-api-params]] ==== Path parameters `id`:: @@ -22,7 +22,7 @@ Updates the attributes for an existing connector. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-update-request-body]] +[[update-connector-api-request-body]] ==== Request body `name`:: @@ -34,13 +34,13 @@ Updates the attributes for an existing connector. `secrets`:: (Required, object) The updated secrets configuration for the connector. Secrets properties vary depending on the connector type. For information about the secrets configuration properties, refer to <>. -[[actions-and-connectors-api-update-codes]] +[[update-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-update-example]] +[[update-connector-api-example]] ==== Example [source,sh] diff --git a/docs/apm/getting-started.asciidoc b/docs/apm/getting-started.asciidoc index c185fdb43faf1..e448c0beb8b99 100644 --- a/docs/apm/getting-started.asciidoc +++ b/docs/apm/getting-started.asciidoc @@ -6,6 +6,24 @@ Get started ++++ +// Conditionally display a screenshot or video depending on what the +// current documentation version is. + +ifeval::["{is-current-version}"=="true"] +++++ + + +
+++++ +endif::[] + For a quick, high-level overview of the health and performance of your application, start with: diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index a3ac62a4c8343..99a6205ae010e 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -8,14 +8,34 @@ requests per minute, and errors per minute. If enabled, service maps also integrate with machine learning--for real time health indicators based on anomaly detection scores. All of these features can help you quickly and visually assess your services' status and health. +// Conditionally display a screenshot or video depending on what the +// current documentation version is. + +ifeval::["{is-current-version}"=="true"] +++++ + + +
+++++ +endif::[] + +ifeval::["{is-current-version}"=="false"] +[role="screenshot"] +image::apm/images/service-maps.png[Example view of service maps in the APM app in Kibana] +endif::[] + We currently surface two types of service maps: * Global: All services instrumented with APM agents and the connections between them are shown. * Service-specific: Highlight connections for a selected service. -[role="screenshot"] -image::apm/images/service-maps.png[Example view of service maps in the APM app in Kibana] - [float] [[service-maps-how]] === How do service maps work? diff --git a/docs/apm/service-overview.asciidoc b/docs/apm/service-overview.asciidoc index 36d021d64456e..693046d652943 100644 --- a/docs/apm/service-overview.asciidoc +++ b/docs/apm/service-overview.asciidoc @@ -38,6 +38,8 @@ image::apm/images/traffic-transactions.png[Traffic and transactions] === Error rate and errors The *Error rate* chart displays the average error rates relating to the service, within a specific time range. +An HTTP response code greater than 400 does not necessarily indicate a failed transaction. +<>. The *Errors* table provides a high-level view of each error message when it first and last occurred, along with the total number of occurrences. This makes it very easy to quickly see which errors affect diff --git a/docs/apm/transactions.asciidoc b/docs/apm/transactions.asciidoc index 8c8da81aa577e..c2a3e0bc2502d 100644 --- a/docs/apm/transactions.asciidoc +++ b/docs/apm/transactions.asciidoc @@ -22,11 +22,21 @@ Visualize response codes: `2xx`, `3xx`, `4xx`, etc. Useful for determining if more responses than usual are being served with a particular response code. Like in the latency graph, you can zoom in on anomalies to further investigate them. +[[transaction-error-rate]] *Error rate*:: -Visualize the total number of transactions with errors divided by the total number of transactions. -The error rate value is based on the `event.outcome` field and is the relative number of failed transactions. -Any unexpected increases, decreases, or irregular patterns can be investigated further -with the <>. +The error rate represents the percentage of failed transactions from the perspective of the selected service. +It's useful for visualizing unexpected increases, decreases, or irregular patterns in a service's transactions. ++ +[TIP] +==== +HTTP **transactions** from the HTTP server perspective do not consider a `4xx` status code (client error) as a failure +because the failure was caused by the caller, not the HTTP server. Thus, there will be no increase in error rate. + +HTTP **spans** from the client perspective however, are considered failures if the HTTP status code is ≥ 400. +These spans will increase the error rate. + +If there is no HTTP status, both transactions and spans are considered successful unless an error is reported. +==== *Average duration by span type*:: Visualize where your application is spending most of its time. diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index df5ce62cc07af..6ca7a83ac0a03 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -87,7 +87,9 @@ readonly links: { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index da3ae17171c81..3847ab0c6183a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md index 68e9bb09456cd..8da2458cf007e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions +export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions ``` ## Properties @@ -18,4 +18,5 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt | [initialize](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md) | boolean | (default=false) If true, sets all the counter fields to 0 if they don't already exist. Existing fields will be left as-is and won't be incremented. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md) | SavedObjectsMigrationVersion | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | | [refresh](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md) | MutatingOperationRefreshSetting | (default='wait\_for') The Elasticsearch refresh setting for this operation. See [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | +| [upsertAttributes](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md) | Attributes | Attributes to use when upserting the document if it doesn't exist. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md new file mode 100644 index 0000000000000..d5657dd65771f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) > [upsertAttributes](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md) + +## SavedObjectsIncrementCounterOptions.upsertAttributes property + +Attributes to use when upserting the document if it doesn't exist. + +Signature: + +```typescript +upsertAttributes?: Attributes; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md index eb18e064c84e2..59d98bf4d607b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md @@ -9,7 +9,7 @@ Increments all the specified counter fields (by one by default). Creates the doc Signature: ```typescript -incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; +incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; ``` ## Parameters @@ -19,7 +19,7 @@ incrementCounter(type: string, id: string, counterFields: Arraystring | The type of saved object whose fields should be incremented | | id | string | The id of the document whose fields should be incremented | | counterFields | Array<string | SavedObjectsIncrementCounterField> | An array of field names to increment or an array of [SavedObjectsIncrementCounterField](./kibana-plugin-core-server.savedobjectsincrementcounterfield.md) | -| options | SavedObjectsIncrementCounterOptions | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | +| options | SavedObjectsIncrementCounterOptions<T> | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | Returns: @@ -52,5 +52,19 @@ repository 'stats.apiCalls', ]) +// Increment the apiCalls field counter by 4 +repository + .incrementCounter('dashboard_counter_type', 'counter_id', [ + { fieldName: 'stats.apiCalls' incrementBy: 4 }, + ]) + +// Initialize the document with arbitrary fields if not present +repository.incrementCounter<{ appId: string }>( + 'dashboard_counter_type', + 'counter_id', + [ 'stats.apiCalls'], + { upsertAttributes: { appId: 'myId' } } +) + ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index bcf220a9a27e6..d5641107a88aa 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): import("rxjs").Observable>; +fetch$(options?: ISearchOptions): import("rxjs").Observable>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): import("rxjs").ObservableReturns: -`import("rxjs").Observable>` +`import("rxjs").Observable>` diff --git a/docs/maps/images/gs_add_cloropeth_layer.png b/docs/maps/images/gs_add_cloropeth_layer.png index 1528f404026f2..42e00ccc5dd24 100644 Binary files a/docs/maps/images/gs_add_cloropeth_layer.png and b/docs/maps/images/gs_add_cloropeth_layer.png differ diff --git a/docs/maps/images/gs_add_es_document_layer.png b/docs/maps/images/gs_add_es_document_layer.png index f4ffbc581745d..d7616c4b11fe0 100644 Binary files a/docs/maps/images/gs_add_es_document_layer.png and b/docs/maps/images/gs_add_es_document_layer.png differ diff --git a/docs/maps/images/sample_data_web_logs.png b/docs/maps/images/sample_data_web_logs.png index 3b0c2ba3f12c0..f4f4de88f1992 100644 Binary files a/docs/maps/images/sample_data_web_logs.png and b/docs/maps/images/sample_data_web_logs.png differ diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index c62aafac00d3f..39ea4daf2ba33 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -67,8 +67,9 @@ and lighter shades will symbolize countries with less traffic. . In **Layer style**, set: -** **Fill color** to the grey color ramp +** **Fill color: As number** to the grey color ramp ** **Border color** to white +** **Label** to symbol label . Click **Save & close**. + @@ -102,7 +103,7 @@ The layer is only visible when users zoom in. . In **Layer settings**, set: ** **Name** to `Actual Requests` -** **Visibilty** to the range [9, 24] +** **Visibility** to the range [9, 24] ** **Opacity** to 100% . Add a tooltip field and select **agent**, **bytes**, **clientip**, **host**, @@ -134,9 +135,9 @@ grids with less bytes transferred. ** **Name** to `Total Requests and Bytes` ** **Visibility** to the range [0, 9] ** **Opacity** to 100% -. Add a metric with: -** **Aggregation** set to **Sum** -** **Field** set to **bytes** +. In **Metrics**, use: +** **Agregation** set to **Count**, and +** **Aggregation** set to **Sum** with **Field** set to **bytes** . In **Layer style**, change **Symbol size**: ** Set the field select to *sum bytes*. ** Set the min size to 7 and the max size to 25 px. diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index f48dbeab9d61a..6483442248cea 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -37,11 +37,6 @@ For more information, see monitoring back-end does not run and {kib} stats are not sent to the monitoring cluster. -a|`monitoring.cluster_alerts.` -`email_notifications.email_address` {ess-icon} - | Specifies the email address where you want to receive cluster alerts. - See <> for details. - | `monitoring.ui.elasticsearch.hosts` | Specifies the location of the {es} cluster where your monitoring data is stored. By default, this is the same as <>. This setting enables diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index cc6e363872808..8603ca9935cac 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -54,6 +54,14 @@ This section highlights common causes of {kib} upgrade failures and how to preve ===== Corrupt saved objects We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. +For example, given the following error message: +> Unable to migrate the corrupt saved object document with _id: 'marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275'. To allow migrations to proceed, please delete this document from the [.kibana_7.12.0_001] index. + +The following steps must be followed to allow the upgrade migration to succeed. +Please be aware the Dashboard having ID `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` belonging to the space `marketing_space` will no more be available: +1. Delete the corrupt document with `DELETE .kibana_7.12.0_001/_doc/marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275` +2. Restart {kib} + [float] ===== User defined index templates that causes new `.kibana*` indices to have incompatible settings or mappings Matching index templates which specify `settings.refresh_interval` or `mappings` are known to interfere with {kib} upgrades. @@ -120,4 +128,4 @@ In order to rollback after a failed upgrade migration, the saved object indices [[upgrade-migrations-old-indices]] ==== Handling old `.kibana_N` indices -After migrations have completed, there will be multiple {kib} indices in {es}: (`.kibana_1`, `.kibana_2`, `.kibana_7.12.0` etc). {kib} only uses the index that the `.kibana` and `.kibana_task_manager` alias points to. The other {kib} indices can be safely deleted, but are left around as a matter of historical record, and to facilitate rolling {kib} back to a previous version. \ No newline at end of file +After migrations have completed, there will be multiple {kib} indices in {es}: (`.kibana_1`, `.kibana_2`, `.kibana_7.12.0` etc). {kib} only uses the index that the `.kibana` and `.kibana_task_manager` alias points to. The other {kib} indices can be safely deleted, but are left around as a matter of historical record, and to facilitate rolling {kib} back to a previous version. diff --git a/docs/user/dashboard/images/lens_advanced_1_1_2.png b/docs/user/dashboard/images/lens_advanced_1_1_2.png index 8b5fe130ce7b7..0247ecf695057 100644 Binary files a/docs/user/dashboard/images/lens_advanced_1_1_2.png and b/docs/user/dashboard/images/lens_advanced_1_1_2.png differ diff --git a/docs/user/dashboard/images/lens_advanced_2_2_1.png b/docs/user/dashboard/images/lens_advanced_2_2_1.png index 3124dd1de0654..3044f1070367d 100644 Binary files a/docs/user/dashboard/images/lens_advanced_2_2_1.png and b/docs/user/dashboard/images/lens_advanced_2_2_1.png differ diff --git a/docs/user/dashboard/images/lens_advanced_3_1_1.png b/docs/user/dashboard/images/lens_advanced_3_1_1.png index 4d52a23cc2cff..c3fb697666b46 100644 Binary files a/docs/user/dashboard/images/lens_advanced_3_1_1.png and b/docs/user/dashboard/images/lens_advanced_3_1_1.png differ diff --git a/docs/user/monitoring/cluster-alerts.asciidoc b/docs/user/monitoring/cluster-alerts.asciidoc deleted file mode 100644 index 2945ebc67710c..0000000000000 --- a/docs/user/monitoring/cluster-alerts.asciidoc +++ /dev/null @@ -1,64 +0,0 @@ -[role="xpack"] -[[cluster-alerts]] -= Cluster Alerts - -The *Stack Monitoring > Clusters* page in {kib} summarizes the status of your -{stack}. You can drill down into the metrics to view more information about your -cluster and specific nodes, instances, and indices. - -The Top Cluster Alerts shown on the Clusters page notify you of -conditions that require your attention: - -* {es} Cluster Health Status is Yellow (missing at least one replica) -or Red (missing at least one primary). -* {es} Version Mismatch. You have {es} nodes with -different versions in the same cluster. -* {kib} Version Mismatch. You have {kib} instances with different -versions running against the same {es} cluster. -* Logstash Version Mismatch. You have Logstash nodes with different -versions reporting stats to the same monitoring cluster. -* {es} Nodes Changed. You have {es} nodes that were recently added or removed. -* {es} License Expiration. The cluster's license is about to expire. -+ --- -If you do not preserve the data directory when upgrading a {kib} or -Logstash node, the instance is assigned a new persistent UUID and shows up -as a new instance --- -* {xpack} License Expiration. When the {xpack} license expiration date -approaches, you will get notifications with a severity level relative to how -soon the expiration date is: - ** 60 days: Informational alert - ** 30 days: Low-level alert - ** 15 days: Medium-level alert - ** 7 days: Severe-level alert -+ -The 60-day and 30-day thresholds are skipped for Trial licenses, which are only -valid for 30 days. - -The {monitor-features} check the cluster alert conditions every minute. Cluster -alerts are automatically dismissed when the condition is resolved. - -NOTE: {watcher} must be enabled to view cluster alerts. If you have a Basic -license, Top Cluster Alerts are not displayed. - -[float] -[[cluster-alert-email-notifications]] -== Email Notifications -To receive email notifications for the Cluster Alerts: - -. Configure an email account as described in -{ref}/actions-email.html#configuring-email[Configuring email accounts]. -. Configure the -`monitoring.cluster_alerts.email_notifications.email_address` setting in -`kibana.yml` with your email address. -+ --- -TIP: If you have separate production and monitoring clusters and separate {kib} -instances for those clusters, you must put the -`monitoring.cluster_alerts.email_notifications.email_address` setting in -the {kib} instance that is associated with the production cluster. - --- - -Email notifications are sent only when Cluster Alerts are triggered and resolved. diff --git a/docs/user/monitoring/index.asciidoc b/docs/user/monitoring/index.asciidoc index 514988792d214..e4fd4a8cd085c 100644 --- a/docs/user/monitoring/index.asciidoc +++ b/docs/user/monitoring/index.asciidoc @@ -1,6 +1,5 @@ include::xpack-monitoring.asciidoc[] include::beats-details.asciidoc[leveloffset=+1] -include::cluster-alerts.asciidoc[leveloffset=+1] include::elasticsearch-details.asciidoc[leveloffset=+1] include::kibana-alerts.asciidoc[leveloffset=+1] include::kibana-details.asciidoc[leveloffset=+1] diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index 300497126c3e5..04f4e986ca289 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -29,7 +29,7 @@ To review and modify all the available alerts, use This alert is triggered when a node runs a consistently high CPU load. By default, the trigger condition is set at 85% or more averaged over the last 5 minutes. The alert is grouped across all the nodes of the cluster by running -checks on a schedule time of 1 minute with a re-notify internal of 1 day. +checks on a schedule time of 1 minute with a re-notify interval of 1 day. [discrete] [[kibana-alerts-disk-usage-threshold]] @@ -38,7 +38,7 @@ checks on a schedule time of 1 minute with a re-notify internal of 1 day. This alert is triggered when a node is nearly at disk capacity. By default, the trigger condition is set at 80% or more averaged over the last 5 minutes. The alert is grouped across all the nodes of the cluster by running -checks on a schedule time of 1 minute with a re-notify internal of 1 day. +checks on a schedule time of 1 minute with a re-notify interval of 1 day. [discrete] [[kibana-alerts-jvm-memory-threshold]] @@ -47,7 +47,7 @@ checks on a schedule time of 1 minute with a re-notify internal of 1 day. This alert is triggered when a node runs a consistently high JVM memory usage. By default, the trigger condition is set at 85% or more averaged over the last 5 minutes. The alert is grouped across all the nodes of the cluster by running -checks on a schedule time of 1 minute with a re-notify internal of 1 day. +checks on a schedule time of 1 minute with a re-notify interval of 1 day. [discrete] [[kibana-alerts-missing-monitoring-data]] @@ -56,7 +56,72 @@ checks on a schedule time of 1 minute with a re-notify internal of 1 day. This alert is triggered when any stack products nodes or instances stop sending monitoring data. By default, the trigger condition is set to missing for 15 minutes looking back 1 day. The alert is grouped across all the nodes of the cluster by running -checks on a schedule time of 1 minute with a re-notify internal of 6 hours. +checks on a schedule time of 1 minute with a re-notify interval of 6 hours. + +[discrete] +[[kibana-alerts-thread-pool-rejections]] +== Thread pool rejections (search/write) + +This alert is triggered when a node experiences thread pool rejections. By +default, the trigger condition is set at 300 or more over the last 5 +minutes. The alert is grouped across all the nodes of the cluster by running +checks on a schedule time of 1 minute with a re-notify interval of 1 day. +Thresholds can be set independently for `search` and `write` type rejections. + +[discrete] +[[kibana-alerts-ccr-read-exceptions]] +== CCR read exceptions + +This alert is triggered if a read exception has been detected on any of the +replicated clusters. The trigger condition is met if 1 or more read exceptions +are detected in the last hour. The alert is grouped across all replicated clusters +by running checks on a schedule time of 1 minute with a re-notify interval of 6 hours. + +[discrete] +[[kibana-alerts-large-shard-size]] +== Large shard size + +This alert is triggered if a large (primary) shard size is found on any of the +specified index patterns. The trigger condition is met if an index's shard size is +55gb or higher in the last 5 minutes. The alert is grouped across all indices that match +the default patter of `*` by running checks on a schedule time of 1 minute with a re-notify +interval of 12 hours. + +[discrete] +[[kibana-alerts-cluster-alerts]] +== Cluster alerts + +These alerts summarize the current status of your {stack}. You can drill down into the metrics +to view more information about your cluster and specific nodes, instances, and indices. + +An alert will be triggered if any of the following conditions are met within the last minute: + +* {es} cluster health status is yellow (missing at least one replica) +or red (missing at least one primary). +* {es} version mismatch. You have {es} nodes with +different versions in the same cluster. +* {kib} version mismatch. You have {kib} instances with different +versions running against the same {es} cluster. +* Logstash version mismatch. You have Logstash nodes with different +versions reporting stats to the same monitoring cluster. +* {es} nodes changed. You have {es} nodes that were recently added or removed. +* {es} license expiration. The cluster's license is about to expire. ++ +-- +If you do not preserve the data directory when upgrading a {kib} or +Logstash node, the instance is assigned a new persistent UUID and shows up +as a new instance +-- +* Subscription license expiration. When the expiration date +approaches, you will get notifications with a severity level relative to how +soon the expiration date is: + ** 60 days: Informational alert + ** 30 days: Low-level alert + ** 15 days: Medium-level alert + ** 7 days: Severe-level alert ++ +The 60-day and 30-day thresholds are skipped for Trial licenses, which are only +valid for 30 days. NOTE: Some action types are subscription features, while others are free. For a comparison of the Elastic subscription levels, see the alerting section of diff --git a/docs/user/production-considerations/alerting-production-considerations.asciidoc b/docs/user/production-considerations/alerting-production-considerations.asciidoc index 57c255c809dc5..6294a4fe6f14a 100644 --- a/docs/user/production-considerations/alerting-production-considerations.asciidoc +++ b/docs/user/production-considerations/alerting-production-considerations.asciidoc @@ -49,3 +49,16 @@ It is difficult to predict how much throughput is needed to ensure all rules and By counting rules as recurring tasks and actions as non-recurring tasks, a rough throughput <> as a _tasks per minute_ measurement. Predicting the buffer required to account for actions depends heavily on the rule types you use, the amount of alerts they might detect, and the number of actions you might choose to assign to action groups. With that in mind, regularly <> of your Task Manager instances. + +[float] +[[event-log-ilm]] +=== Event log index lifecycle managment + +Alerts and actions log activity in a set of "event log" indices. These indices are configured with an index lifecycle management (ILM) policy, which you can customize. The default policy rolls over the index when it reaches 50GB, or after 30 days. Indices over 90 days old are deleted. + +The name of the index policy is `kibana-event-log-policy`. {kib} creates the index policy on startup, if it doesn't already exist. The index policy can be customized for your environment, but {kib} never modifies the index policy after creating it. + +Because Kibana uses the documents to display historic data, you should set the delete phase longer than you would like the historic data to be shown. For example, if you would like to see one month's worth of historic data, you should set the delete phase to at least one month. + +For more information on index lifecycle management, see: +{ref}/index-lifecycle-management.html[Index Lifecycle Policies]. diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index c96b294c0c50d..5e75aef0d9570 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -706,3 +706,21 @@ These rough calculations give you a lower bound to the required throughput, whic Given these inferred attributes, it would be safe to assume that a single {kib} instance with default settings **would not** provide the required throughput. It is possible that scaling horizontally by adding a couple more {kib} instances will. For details on scaling Task Manager, see <>. + +[float] +[[task-manager-cannot-operate-when-inline-scripts-are-disabled]] +==== Inline scripts are disabled in {es} + +*Problem*: + +Tasks are not running, and the server logs contain the following error message: + +[source, txt] +-------------------------------------------------- +[warning][plugins][taskManager] Task Manager cannot operate when inline scripts are disabled in {es} +-------------------------------------------------- + +*Solution*: + +Inline scripts are a hard requirement for Task Manager to function. +To enable inline scripting, see the Elasticsearch documentation for {ref}/modules-scripting-security.html#allowed-script-types-setting[configuring allowed script types setting]. diff --git a/package.json b/package.json index 66a6ef1d4558b..99591fdc1ea40 100644 --- a/package.json +++ b/package.json @@ -95,18 +95,23 @@ "yarn": "^1.21.1" }, "dependencies": { + "@elastic/apm-rum": "^5.6.1", + "@elastic/apm-rum-react": "^1.2.5", + "@elastic/charts": "26.0.0", "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "31.7.0", + "@elastic/eui": "31.10.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", + "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", "@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", + "@elastic/ui-ace": "0.2.3", "@hapi/boom": "^9.1.1", "@hapi/cookie": "^11.0.2", "@hapi/good-squeeze": "6.0.0", @@ -131,9 +136,15 @@ "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", + "@kbn/utility-types": "link:packages/kbn-utility-types", "@kbn/utils": "link:packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", + "@mapbox/geojson-rewind": "^0.5.0", + "@mapbox/mapbox-gl-draw": "^1.2.0", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@mapbox/vector-tile": "1.3.1", + "@scant/router": "^0.1.1", "@slack/webhook": "^5.0.4", "@turf/along": "6.0.1", "@turf/area": "6.0.1", @@ -151,41 +162,60 @@ "accept": "3.0.2", "ajv": "^6.12.4", "angular": "^1.8.0", + "angular-aria": "^1.8.0", "angular-elastic": "^2.5.1", + "angular-recursion": "^1.0.5", "angular-resource": "1.8.0", + "angular-route": "^1.8.0", "angular-sanitize": "^1.8.0", + "angular-sortable-view": "^0.0.17", "angular-ui-ace": "0.2.3", "antlr4ts": "^0.5.0-alpha.3", "apollo-cache-inmemory": "1.6.2", "apollo-client": "^2.3.8", + "apollo-link": "^1.2.3", + "apollo-link-error": "^1.1.7", "apollo-link-http": "^1.5.16", "apollo-link-http-common": "^0.2.15", "apollo-link-schema": "^1.1.0", + "apollo-link-state": "^0.4.1", "apollo-server-core": "^1.3.6", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", "archiver": "^5.2.0", "axios": "^0.21.1", + "base64-js": "^1.3.1", "bluebird": "3.5.5", "brace": "0.11.1", + "broadcast-channel": "^3.0.3", "chalk": "^4.1.0", "check-disk-space": "^2.1.0", + "cheerio": "0.22.0", "chokidar": "^3.4.3", "chroma-js": "^1.4.1", "classnames": "2.2.6", "color": "1.0.3", "commander": "^3.0.2", + "compare-versions": "3.5.1", "concat-stream": "1.6.2", + "constate": "^1.3.2", + "cronstrue": "^1.51.0", "content-disposition": "0.5.3", + "copy-to-clipboard": "^3.0.8", "core-js": "^3.6.5", + "css-minimizer-webpack-plugin": "^1.3.0", "custom-event-polyfill": "^0.3.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", + "d3": "3.5.17", "d3-array": "1.2.4", + "d3-cloud": "1.2.5", + "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", "dedent": "^0.7.0", "deep-freeze-strict": "^1.1.1", + "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.10.0", "elasticsearch": "^16.7.0", @@ -194,9 +224,11 @@ "expiry-js": "0.1.7", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.1", + "file-saver": "^1.3.8", "file-type": "^10.9.0", "focus-trap-react": "^3.1.1", "font-awesome": "4.7.0", + "formsy-react": "^1.1.5", "fp-ts": "^2.3.1", "geojson-vt": "^3.2.1", "get-port": "^5.0.0", @@ -212,31 +244,51 @@ "graphql-tag": "^2.10.3", "graphql-tools": "^3.0.2", "handlebars": "4.7.7", + "he": "^1.2.0", "history": "^4.9.0", + "history-extra": "^5.0.1", "hjson": "3.2.1", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", + "i18n-iso-countries": "^4.3.1", + "icalendar": "0.7.1", "idx": "^2.5.6", "immer": "^8.0.1", "inline-style": "^2.0.0", "intl": "^1.2.5", "intl-format-cache": "^2.1.0", "intl-messageformat": "^2.2.0", + "intl-messageformat-parser": "^1.4.0", "intl-relativeformat": "^2.1.0", "io-ts": "^2.0.5", "ipaddr.js": "2.0.0", "isbinaryfile": "4.0.2", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", + "js-search": "^1.4.3", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", + "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", "jsonwebtoken": "^8.5.1", + "jsts": "^1.6.2", + "kea": "^2.3.0", + "leaflet": "1.5.1", + "leaflet-draw": "0.4.14", + "leaflet-responsive-popup": "0.6.4", + "leaflet.heat": "0.2.0", + "less": "npm:@elastic/less@2.7.3-kibana", "load-json-file": "^6.2.0", + "loader-utils": "^1.2.3", "lodash": "^4.17.21", "lru-cache": "^4.1.5", + "lz-string": "^1.4.4", "markdown-it": "^10.0.0", + "mapbox-gl": "1.13.1", + "mapbox-gl-draw-rectangle-mode": "^1.0.4", "md5": "^2.1.0", + "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "0.8.0", @@ -261,38 +313,69 @@ "papaparse": "^5.2.0", "pdfmake": "^0.1.65", "pegjs": "0.10.0", + "p-limit": "^3.0.1", + "pluralize": "3.1.0", "pngjs": "^3.4.0", + "polished": "^1.9.2", "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", + "proxyquire": "1.8.0", "puid": "1.0.7", "puppeteer": "npm:@elastic/puppeteer@5.4.1-patch.1", "query-string": "^6.13.2", "raw-loader": "^3.1.0", + "rbush": "^3.0.1", + "re-resizable": "^6.1.1", "re2": "^1.15.4", "react": "^16.12.0", "react-ace": "^5.9.0", + "react-apollo": "^2.1.4", + "react-beautiful-dnd": "^13.0.0", "react-color": "^2.13.8", "react-datetime": "^2.14.0", "react-dom": "^16.12.0", + "react-dropzone": "^4.2.9", + "react-fast-compare": "^2.0.4", + "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", "react-intl": "^2.8.0", "react-is": "^16.8.0", + "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", + "react-monaco-editor": "^0.41.2", + "react-popper-tooltip": "^2.10.1", "react-query": "^3.12.0", + "react-resize-detector": "^4.2.0", + "react-reverse-portal": "^1.0.4", + "react-router-redux": "^4.0.8", + "react-shortcuts": "^2.0.0", + "react-sizeme": "^2.3.6", + "react-syntax-highlighter": "^15.3.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-tiny-virtual-list": "^2.2.0", + "react-virtualized": "^9.21.2", "react-use": "^15.3.8", + "react-vis": "^1.8.1", + "react-visibility-sensor": "^5.1.1", + "reactcss": "1.2.3", "recompose": "^0.26.0", + "reduce-reducers": "^1.0.4", "redux": "^4.0.5", "redux-actions": "^2.6.5", + "redux-devtools-extension": "^2.13.8", "redux-observable": "^1.2.0", + "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", + "redux-thunks": "^1.0.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", + "reselect": "^4.0.0", + "resize-observer-polyfill": "^1.5.0", "rison-node": "1.0.2", "rxjs": "^6.5.5", "seedrandom": "^3.0.5", @@ -305,17 +388,30 @@ "style-it": "^2.1.3", "styled-components": "^5.1.0", "symbol-observable": "^1.2.0", + "suricata-sid-db": "^1.0.2", "tabbable": "1.1.3", "tar": "4.4.13", + "tinycolor2": "1.4.1", "tinygradient": "0.4.3", + "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", "type-detect": "^4.0.8", + "typescript-fsa": "^3.0.0", + "typescript-fsa-reducers": "^1.2.2", "ui-select": "0.19.8", "unified": "^9.2.1", + "unstated": "^2.1.1", + "use-resize-observer": "^6.0.0", "utility-types": "^3.10.0", "uuid": "3.3.2", + "vega": "^5.19.1", + "vega-lite": "^4.17.0", + "vega-schema-url-parser": "^2.1.0", + "vega-spec-injector": "^0.0.2", + "vega-tooltip": "^0.25.0", + "venn.js": "0.2.20", "vinyl": "^2.2.0", "vt-pbf": "^3.1.1", "wellknown": "^0.5.0", @@ -347,13 +443,10 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "25.3.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", - "@elastic/maki": "6.3.0", - "@elastic/ui-ace": "0.2.3", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.5.2", "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", @@ -373,17 +466,11 @@ "@kbn/telemetry-tools": "link:packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", - "@kbn/utility-types": "link:packages/kbn-utility-types", "@loaders.gl/polyfills": "^2.3.5", - "@mapbox/geojson-rewind": "^0.5.0", - "@mapbox/mapbox-gl-draw": "^1.2.0", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", - "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.7.2", "@microsoft/api-extractor": "7.7.0", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@scant/router": "^0.1.0", "@storybook/addon-a11y": "^6.1.20", "@storybook/addon-actions": "^6.1.20", "@storybook/addon-docs": "^6.1.20", @@ -456,7 +543,6 @@ "@types/he": "^1.1.1", "@types/history": "^4.7.3", "@types/hjson": "^2.4.2", - "@types/hoist-non-react-statics": "^3.3.1", "@types/http-proxy": "^1.17.4", "@types/http-proxy-agent": "^2.0.2", "@types/inquirer": "^7.3.1", @@ -476,7 +562,6 @@ "@types/listr": "^0.14.0", "@types/loader-utils": "^1.1.3", "@types/lodash": "^4.14.159", - "@types/log-symbols": "^2.0.0", "@types/lru-cache": "^5.1.0", "@types/mapbox-gl": "^1.9.1", "@types/markdown-it": "^0.0.7", @@ -563,21 +648,13 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", - "@welldone-software/why-did-you-render": "^5.0.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^2.0.4", "aggregate-error": "^3.1.0", - "angular-aria": "^1.8.0", "angular-mocks": "^1.7.9", - "angular-recursion": "^1.0.5", - "angular-route": "^1.8.0", - "angular-sortable-view": "^0.0.17", "antlr4ts-cli": "^0.5.0-alpha.3", "apidoc": "^0.25.0", "apidoc-markdown": "^5.1.8", - "apollo-link": "^1.2.3", - "apollo-link-error": "^1.1.7", - "apollo-link-state": "^0.4.1", "argsplit": "^1.0.5", "autoprefixer": "^9.7.4", "axe-core": "^4.0.2", @@ -590,34 +667,23 @@ "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "^5.6.6", - "base64-js": "^1.3.1", "base64url": "^3.0.1", - "broadcast-channel": "^3.0.3", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "cheerio": "0.22.0", "chromedriver": "^89.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", - "compare-versions": "3.5.1", "compression-webpack-plugin": "^4.0.0", - "constate": "^1.3.2", - "copy-to-clipboard": "^3.0.8", "copy-webpack-plugin": "^6.0.2", "cpy": "^8.1.1", - "cronstrue": "^1.51.0", "css-loader": "^3.4.2", "cypress": "^6.2.1", "cypress-cucumber-preprocessor": "^2.5.2", "cypress-multi-reporters": "^1.4.0", "cypress-pipe": "^2.0.0", "cypress-promise": "^1.1.0", - "d3": "3.5.17", - "d3-cloud": "1.2.5", - "d3-scale": "1.0.7", "debug": "^2.6.9", - "deepmerge": "^4.2.2", "del-cli": "^3.0.1", "delete-empty": "^2.0.0", "dependency-check": "^4.1.0", @@ -654,9 +720,7 @@ "fast-glob": "2.2.7", "fetch-mock": "^7.3.9", "file-loader": "^4.2.0", - "file-saver": "^1.3.8", "form-data": "^4.0.0", - "formsy-react": "^1.1.5", "geckodriver": "^1.22.2", "glob-watcher": "5.0.3", "graphql-code-generator": "^0.18.2", @@ -675,19 +739,10 @@ "gulp-zip": "^5.0.2", "has-ansi": "^3.0.0", "hdr-histogram-js": "^1.2.0", - "he": "^1.2.0", - "highlight.js": "^9.18.5", - "history-extra": "^5.0.1", - "hoist-non-react-statics": "^3.3.2", "html": "1.0.0", "html-loader": "^0.5.5", "http-proxy": "^1.18.1", - "i18n-iso-countries": "^4.3.1", - "icalendar": "0.7.1", - "iedriver": "^3.14.2", - "imports-loader": "^0.8.0", "inquirer": "^7.3.3", - "intl-messageformat-parser": "^1.4.0", "is-glob": "^4.0.1", "is-path-inside": "^3.0.2", "istanbul-instrumenter-loader": "^3.0.1", @@ -705,31 +760,14 @@ "jest-styled-components": "^7.0.2", "jest-when": "^2.7.2", "jimp": "^0.14.0", - "js-levenshtein": "^1.1.6", - "js-search": "^1.4.3", "jsdom": "13.1.0", - "json-stringify-pretty-compact": "1.2.0", "json5": "^1.0.1", "jsondiffpatch": "0.4.1", - "jsts": "^1.6.2", - "kea": "^2.3.0", - "keymirror": "0.1.1", - "leaflet": "1.5.1", - "leaflet-draw": "0.4.14", - "leaflet-responsive-popup": "0.6.4", - "leaflet.heat": "0.2.0", - "less": "npm:@elastic/less@2.7.3-kibana", "license-checker": "^16.0.0", "listr": "^0.14.1", "lmdb-store": "^0.9.0", "load-grunt-config": "^3.0.1", - "loader-utils": "^1.2.3", - "log-symbols": "^2.2.0", - "lz-string": "^1.4.4", - "mapbox-gl": "1.13.1", - "mapbox-gl-draw-rectangle-mode": "^1.0.4", "marge": "^1.0.1", - "memoize-one": "^5.0.0", "micromatch": "3.1.10", "minimist": "^1.2.5", "mkdirp": "0.5.1", @@ -741,8 +779,6 @@ "mock-http-server": "1.3.0", "ms-chromium-edge-driver": "^0.2.3", "multimatch": "^4.0.0", - "multistream": "^2.1.1", - "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", "node-sass": "^4.14.1", @@ -750,53 +786,19 @@ "nyc": "^15.0.1", "oboe": "^2.1.4", "ora": "^4.0.4", - "p-limit": "^3.0.1", "parse-link-header": "^1.0.1", "pbf": "3.2.1", "pirates": "^4.0.1", "pixelmatch": "^5.1.0", - "pkg-up": "^2.0.0", - "pluralize": "3.1.0", - "polished": "^1.9.2", "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "postcss-prefix-selector": "^1.7.2", "prettier": "^2.2.0", "pretty-ms": "5.0.0", - "proxyquire": "1.8.0", "q": "^1.5.1", - "querystring": "^0.2.0", - "rbush": "^3.0.1", - "re-resizable": "^6.1.1", - "react-apollo": "^2.1.4", - "react-beautiful-dnd": "^13.0.0", - "react-docgen-typescript-loader": "^3.1.1", - "react-dropzone": "^4.2.9", - "react-fast-compare": "^2.0.4", - "react-grid-layout": "^0.16.2", - "react-markdown": "^4.3.1", - "react-monaco-editor": "^0.41.2", - "react-popper-tooltip": "^2.10.1", - "react-resize-detector": "^4.2.0", - "react-reverse-portal": "^1.0.4", - "react-router-redux": "^4.0.8", - "react-shortcuts": "^2.0.0", - "react-sizeme": "^2.3.6", - "react-syntax-highlighter": "^15.3.1", "react-test-renderer": "^16.12.0", - "react-tiny-virtual-list": "^2.2.0", - "react-virtualized": "^9.21.2", - "react-vis": "^1.8.1", - "react-visibility-sensor": "^5.1.1", - "reactcss": "1.2.3", "read-pkg": "^5.2.0", - "reduce-reducers": "^1.0.4", - "redux-devtools-extension": "^2.13.8", - "redux-saga": "^1.1.3", - "redux-thunks": "^1.0.0", "regenerate": "^1.4.0", - "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", "resolve": "^1.7.1", "rxjs-marbles": "^5.0.6", "sass-loader": "^8.0.2", @@ -816,31 +818,18 @@ "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", "supports-color": "^7.0.0", - "suricata-sid-db": "^1.0.2", "tape": "^5.0.1", "tar-fs": "^2.1.0", "tempy": "^0.3.0", "terminal-link": "^2.1.1", "terser-webpack-plugin": "^2.1.2", - "tinycolor2": "1.4.1", - "topojson-client": "3.0.0", "ts-loader": "^7.0.5", "ts-morph": "^9.1.0", "tsd": "^0.13.1", "typescript": "4.1.3", - "typescript-fsa": "^3.0.0", - "typescript-fsa-reducers": "^1.2.2", "unlazy-loader": "^0.1.3", - "unstated": "^2.1.1", "url-loader": "^2.2.0", - "use-resize-observer": "^6.0.0", "val-loader": "^1.1.1", - "vega": "^5.19.1", - "vega-lite": "^4.17.0", - "vega-schema-url-parser": "^2.1.0", - "vega-spec-injector": "^0.0.2", - "vega-tooltip": "^0.25.0", - "venn.js": "0.2.20", "vinyl-fs": "^3.0.3", "wait-on": "^5.2.1", "watchpack": "^1.6.0", diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json index 99b108eb2e6b3..e9b87aa0f972f 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json @@ -2,6 +2,45 @@ "id": "pluginA", "client": { "classes": [ + { + "id": "def-public.CrazyClass", + "type": "Class", + "tags": [], + "label": "CrazyClass", + "description": [], + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.CrazyClass", + "text": "CrazyClass" + }, + "

extends ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ExampleClass", + "text": "ExampleClass" + }, + "<", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.WithGen", + "text": "WithGen" + }, + "

>" + ], + "children": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 65 + }, + "initialIsOpen": false + }, { "id": "def-public.ExampleClass", "type": "Class", @@ -35,8 +74,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 44, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L44" + "lineNumber": 44 }, "signature": [ "React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined" @@ -61,8 +99,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 46, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46" + "lineNumber": 46 } } ], @@ -70,8 +107,7 @@ "returnComment": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 46, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46" + "lineNumber": 46 } }, { @@ -94,8 +130,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 54, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L54" + "lineNumber": 54 } } ], @@ -123,8 +158,7 @@ "label": "arrowFn", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 54, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L54" + "lineNumber": 54 }, "tags": [], "returnComment": [] @@ -166,8 +200,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 60, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L60" + "lineNumber": 60 } } ], @@ -175,200 +208,18 @@ "returnComment": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 60, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L60" + "lineNumber": 60 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 38, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L38" - }, - "initialIsOpen": false - }, - { - "id": "def-public.CrazyClass", - "type": "Class", - "tags": [], - "label": "CrazyClass", - "description": [], - "signature": [ - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.CrazyClass", - "text": "CrazyClass" - }, - "

extends ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.ExampleClass", - "text": "ExampleClass" - }, - "<", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.WithGen", - "text": "WithGen" - }, - "

>" - ], - "children": [], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 65, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L65" + "lineNumber": 38 }, "initialIsOpen": false } ], "functions": [ - { - "id": "def-public.notAnArrowFn", - "type": "Function", - "label": "notAnArrowFn", - "signature": [ - "(a: string, b: number | undefined, c: ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" - }, - ", d: ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.ImAType", - "text": "ImAType" - }, - ", e: string | undefined) => ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" - }, - "" - ], - "description": [ - "\nThis is a non arrow function.\n" - ], - "children": [ - { - "type": "string", - "label": "a", - "isRequired": true, - "signature": [ - "string" - ], - "description": [ - "The letter A" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 22, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L22" - } - }, - { - "type": "number", - "label": "b", - "isRequired": false, - "signature": [ - "number | undefined" - ], - "description": [ - "Feed me to the function" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 23, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L23" - } - }, - { - "type": "Array", - "label": "c", - "isRequired": true, - "signature": [ - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" - }, - "" - ], - "description": [ - "So many params" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 24, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L24" - } - }, - { - "type": "CompoundType", - "label": "d", - "isRequired": true, - "signature": [ - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.ImAType", - "text": "ImAType" - } - ], - "description": [ - "a great param" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 25, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L25" - } - }, - { - "type": "string", - "label": "e", - "isRequired": false, - "signature": [ - "string | undefined" - ], - "description": [ - "Another comment" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 26, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L26" - } - } - ], - "tags": [], - "returnComment": [ - "something!" - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 21, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L21" - }, - "initialIsOpen": false - }, { "id": "def-public.arrowFn", "type": "Function", @@ -383,8 +234,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 42, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L42" + "lineNumber": 42 } }, { @@ -397,8 +247,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 43, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L43" + "lineNumber": 43 } }, { @@ -418,8 +267,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 44, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L44" + "lineNumber": 44 } }, { @@ -438,8 +286,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 45, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L45" + "lineNumber": 45 } }, { @@ -452,8 +299,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 46, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L46" + "lineNumber": 46 } } ], @@ -490,8 +336,7 @@ "label": "arrowFn", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 41, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L41" + "lineNumber": 41 }, "tags": [], "returnComment": [ @@ -518,15 +363,13 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 67, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67" + "lineNumber": 67 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 67, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67" + "lineNumber": 67 } }, { @@ -544,8 +387,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 68, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68" + "lineNumber": 68 }, "signature": [ "(foo: { param: string; }) => number" @@ -554,8 +396,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 68, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68" + "lineNumber": 68 } }, { @@ -573,15 +414,13 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 69, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69" + "lineNumber": 69 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 69, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69" + "lineNumber": 69 } } ], @@ -594,8 +433,7 @@ "label": "crazyFunction", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 66, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L66" + "lineNumber": 66 }, "tags": [], "returnComment": [ @@ -617,8 +455,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 76, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76" + "lineNumber": 76 } } ], @@ -629,102 +466,148 @@ "label": "fnWithNonExportedRef", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 76, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76" + "lineNumber": 76 }, "tags": [], "returnComment": [], "initialIsOpen": false - } - ], - "interfaces": [ + }, { - "id": "def-public.SearchSpec", - "type": "Interface", - "label": "SearchSpec", + "id": "def-public.notAnArrowFn", + "type": "Function", + "label": "notAnArrowFn", + "signature": [ + "(a: string, b: number | undefined, c: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + ", d: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ", e: string | undefined) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], "description": [ - "\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password." + "\nThis is a non arrow function.\n" ], - "tags": [], "children": [ { - "tags": [], - "id": "def-public.SearchSpec.username", "type": "string", - "label": "username", + "label": "a", + "isRequired": true, + "signature": [ + "string" + ], "description": [ - "\nStores the username. Duh," + "The letter A" ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 26, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L26" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 22 } }, { - "tags": [], - "id": "def-public.SearchSpec.password", - "type": "string", - "label": "password", + "type": "number", + "label": "b", + "isRequired": false, + "signature": [ + "number | undefined" + ], "description": [ - "\nStores the password. I hope it's encrypted!" + "Feed me to the function" ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 30, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L30" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 23 } - } - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 22, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L22" - }, - "initialIsOpen": false - }, - { - "id": "def-public.WithGen", - "type": "Interface", - "label": "WithGen", - "signature": [ + }, { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.WithGen", - "text": "WithGen" + "type": "Array", + "label": "c", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "description": [ + "So many params" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 24 + } }, - "" - ], - "description": [ - "\nAn interface with a generic." - ], - "tags": [], - "children": [ { - "tags": [], - "id": "def-public.WithGen.t", - "type": "Uncategorized", - "label": "t", - "description": [], + "type": "CompoundType", + "label": "d", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [ + "a great param" + ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 31, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L31" - }, + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 25 + } + }, + { + "type": "string", + "label": "e", + "isRequired": false, "signature": [ - "T" - ] + "string | undefined" + ], + "description": [ + "Another comment" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 26 + } } ], + "tags": [], + "returnComment": [ + "something!" + ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 30, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L30" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 21 }, "initialIsOpen": false - }, + } + ], + "interfaces": [ { "id": "def-public.AnotherInterface", "type": "Interface", @@ -750,8 +633,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 35, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L35" + "lineNumber": 35 }, "signature": [ "T" @@ -760,8 +642,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 34, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L34" + "lineNumber": 34 }, "initialIsOpen": false }, @@ -802,8 +683,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 75, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L75" + "lineNumber": 75 }, "signature": [ "() => Promise" @@ -819,8 +699,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 81, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L81" + "lineNumber": 81 }, "signature": [ "(t: T) => void" @@ -841,47 +720,13 @@ "returnComment": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 86, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L86" + "lineNumber": 86 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 71, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L71" - }, - "initialIsOpen": false - }, - { - "id": "def-public.IReturnAReactComponent", - "type": "Interface", - "label": "IReturnAReactComponent", - "description": [ - "\nAn interface that has a react component." - ], - "tags": [], - "children": [ - { - "tags": [], - "id": "def-public.IReturnAReactComponent.component", - "type": "CompoundType", - "label": "component", - "description": [], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 93, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L93" - }, - "signature": [ - "React.ComponentType<{}>" - ] - } - ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", - "lineNumber": 92, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L92" + "lineNumber": 71 }, "initialIsOpen": false }, @@ -900,8 +745,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 44, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L44" + "lineNumber": 44 }, "signature": [ { @@ -916,143 +760,154 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 43, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L43" + "lineNumber": 43 }, "initialIsOpen": false - } - ], - "enums": [ + }, { - "id": "def-public.DayOfWeek", - "type": "Enum", - "label": "DayOfWeek", - "tags": [], + "id": "def-public.IReturnAReactComponent", + "type": "Interface", + "label": "IReturnAReactComponent", "description": [ - "\nComments on enums." + "\nAn interface that has a react component." ], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 31, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L31" - }, - "initialIsOpen": false - } - ], - "misc": [ - { "tags": [], - "id": "def-public.imAnAny", - "type": "Any", - "label": "imAnAny", - "description": [], + "children": [ + { + "tags": [], + "id": "def-public.IReturnAReactComponent.component", + "type": "CompoundType", + "label": "component", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 93 + }, + "signature": [ + "React.ComponentType<{}>" + ] + } + ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", - "lineNumber": 19, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L19" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 92 }, - "signature": [ - "any" - ], "initialIsOpen": false }, { + "id": "def-public.SearchSpec", + "type": "Interface", + "label": "SearchSpec", + "description": [ + "\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password." + ], "tags": [], - "id": "def-public.imAnUnknown", - "type": "Unknown", - "label": "imAnUnknown", - "description": [], + "children": [ + { + "tags": [], + "id": "def-public.SearchSpec.username", + "type": "string", + "label": "username", + "description": [ + "\nStores the username. Duh," + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 26 + } + }, + { + "tags": [], + "id": "def-public.SearchSpec.password", + "type": "string", + "label": "password", + "description": [ + "\nStores the password. I hope it's encrypted!" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 30 + } + } + ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", - "lineNumber": 20, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L20" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 22 }, - "signature": [ - "unknown" - ], "initialIsOpen": false }, { - "id": "def-public.NotAnArrowFnType", - "type": "Type", - "label": "NotAnArrowFnType", - "tags": [], - "description": [], - "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", - "lineNumber": 78, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L78" - }, + "id": "def-public.WithGen", + "type": "Interface", + "label": "WithGen", "signature": [ - "(a: string, b: number | undefined, c: ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" - }, - ", d: ", { "pluginId": "pluginA", "scope": "public", "docId": "kibPluginAPluginApi", - "section": "def-public.ImAType", - "text": "ImAType" + "section": "def-public.WithGen", + "text": "WithGen" }, - ", e: string | undefined) => ", + "" + ], + "description": [ + "\nAn interface with a generic." + ], + "tags": [], + "children": [ { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" - }, - "" + "tags": [], + "id": "def-public.WithGen.t", + "type": "Uncategorized", + "label": "t", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 31 + }, + "signature": [ + "T" + ] + } ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 30 + }, "initialIsOpen": false - }, + } + ], + "enums": [ { + "id": "def-public.DayOfWeek", + "type": "Enum", + "label": "DayOfWeek", "tags": [], - "id": "def-public.aUnionProperty", - "type": "CompoundType", - "label": "aUnionProperty", "description": [ - "\nThis is a complicated union type" + "\nComments on enums." ], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 58, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L58" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 31 }, - "signature": [ - "string | number | (() => string) | ", - { - "pluginId": "pluginA", - "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.CrazyClass", - "text": "CrazyClass" - }, - "" - ], "initialIsOpen": false - }, + } + ], + "misc": [ { "tags": [], - "id": "def-public.aStrArray", - "type": "Array", - "label": "aStrArray", + "id": "def-public.aNum", + "type": "number", + "label": "aNum", "description": [ - "\nThis is an array of strings. The type is explicit." + "\nIt's a number. A special number." ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 63, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L63" + "lineNumber": 78 }, "signature": [ - "string[]" + "10" ], "initialIsOpen": false }, @@ -1066,8 +921,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 68, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L68" + "lineNumber": 68 }, "signature": [ "number[]" @@ -1084,78 +938,104 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 73, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L73" + "lineNumber": 73 }, "initialIsOpen": false }, { "tags": [], - "id": "def-public.aNum", - "type": "number", - "label": "aNum", + "id": "def-public.aStrArray", + "type": "Array", + "label": "aStrArray", "description": [ - "\nIt's a number. A special number." + "\nThis is an array of strings. The type is explicit." ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 78, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L78" + "lineNumber": 63 }, "signature": [ - "10" + "string[]" ], "initialIsOpen": false }, { "tags": [], - "id": "def-public.literalString", - "type": "string", - "label": "literalString", + "id": "def-public.aUnionProperty", + "type": "CompoundType", + "label": "aUnionProperty", "description": [ - "\nI'm a type of string, but more specifically, a literal string type." + "\nThis is a complicated union type" ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 83, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L83" + "lineNumber": 58 }, "signature": [ - "\"HI\"" + "string | number | (() => string) | ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.CrazyClass", + "text": "CrazyClass" + }, + "" ], "initialIsOpen": false }, { - "id": "def-public.StringOrUndefinedType", + "id": "def-public.FnWithGeneric", "type": "Type", - "label": "StringOrUndefinedType", + "label": "FnWithGeneric", "tags": [], "description": [ - "\nHow should a potentially undefined type show up." + "\nThis is a type that defines a function.\n" ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 15, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L15" + "lineNumber": 26 }, "signature": [ - "undefined | string" + "(t: T) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" ], "initialIsOpen": false }, { - "id": "def-public.TypeWithGeneric", - "type": "Type", - "label": "TypeWithGeneric", "tags": [], + "id": "def-public.imAnAny", + "type": "Any", + "label": "imAnAny", "description": [], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 17, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L17" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", + "lineNumber": 19 }, "signature": [ - "T[]" + "any" + ], + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-public.imAnUnknown", + "type": "Unknown", + "label": "imAnUnknown", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", + "lineNumber": 20 + }, + "signature": [ + "unknown" ], "initialIsOpen": false }, @@ -1167,8 +1047,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 19, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L19" + "lineNumber": 19 }, "signature": [ "string | number | ", @@ -1199,28 +1078,41 @@ "initialIsOpen": false }, { - "id": "def-public.FnWithGeneric", + "id": "def-public.IRefANotExportedType", "type": "Type", - "label": "FnWithGeneric", + "label": "IRefANotExportedType", "tags": [], - "description": [ - "\nThis is a type that defines a function.\n" - ], + "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 26, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L26" + "lineNumber": 42 }, "signature": [ - "(t: T) => ", { "pluginId": "pluginA", "scope": "public", - "docId": "kibPluginAPluginApi", - "section": "def-public.TypeWithGeneric", - "text": "TypeWithGeneric" + "docId": "kibPluginAFooPluginApi", + "section": "def-public.ImNotExportedFromIndex", + "text": "ImNotExportedFromIndex" }, - "" + " | { zed: \"hi\"; }" + ], + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-public.literalString", + "type": "string", + "label": "literalString", + "description": [ + "\nI'm a type of string, but more specifically, a literal string type." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 83 + }, + "signature": [ + "\"HI\"" ], "initialIsOpen": false }, @@ -1234,8 +1126,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 40, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L40" + "lineNumber": 40 }, "signature": [ "(typeof DayOfWeek)[]" @@ -1243,25 +1134,73 @@ "initialIsOpen": false }, { - "id": "def-public.IRefANotExportedType", + "id": "def-public.NotAnArrowFnType", "type": "Type", - "label": "IRefANotExportedType", + "label": "NotAnArrowFnType", "tags": [], "description": [], "source": { - "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", - "lineNumber": 42, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L42" + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 78 }, "signature": [ + "(a: string, b: number | undefined, c: ", { "pluginId": "pluginA", "scope": "public", - "docId": "kibPluginAFooPluginApi", - "section": "def-public.ImNotExportedFromIndex", - "text": "ImNotExportedFromIndex" + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" }, - " | { zed: \"hi\"; }" + ", d: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ", e: string | undefined) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "initialIsOpen": false + }, + { + "id": "def-public.StringOrUndefinedType", + "type": "Type", + "label": "StringOrUndefinedType", + "tags": [], + "description": [ + "\nHow should a potentially undefined type show up." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 15 + }, + "signature": [ + "undefined | string" + ], + "initialIsOpen": false + }, + { + "id": "def-public.TypeWithGeneric", + "type": "Type", + "label": "TypeWithGeneric", + "tags": [], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 17 + }, + "signature": [ + "T[]" ], "initialIsOpen": false } @@ -1282,8 +1221,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 21, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L21" + "lineNumber": 21 }, "signature": [ "typeof ", @@ -1306,8 +1244,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 26, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L26" + "lineNumber": 26 }, "signature": [ "typeof ", @@ -1340,8 +1277,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 31, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31" + "lineNumber": 31 } } ], @@ -1369,8 +1305,7 @@ "label": "aPropertyInlineFn", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 31, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31" + "lineNumber": 31 }, "tags": [], "returnComment": [] @@ -1385,8 +1320,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 38, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L38" + "lineNumber": 38 } }, { @@ -1402,8 +1336,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 44, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L44" + "lineNumber": 44 } } ], @@ -1413,8 +1346,7 @@ "label": "nestedObj", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 43, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L43" + "lineNumber": 43 } } ], @@ -1424,8 +1356,7 @@ "label": "aPretendNamespaceObj", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", - "lineNumber": 17, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L17" + "lineNumber": 17 }, "initialIsOpen": false } @@ -1451,8 +1382,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 101, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L101" + "lineNumber": 101 }, "signature": [ "(searchSpec: ", @@ -1476,8 +1406,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 109, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L109" + "lineNumber": 109 }, "signature": [ "(searchSpec: { username: string; password: string; }) => string" @@ -1495,8 +1424,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 122, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L122" + "lineNumber": 122 }, "signature": [ "(thingOne: number, thingTwo: string, thingThree: { nestedVar: number; }) => void" @@ -1512,8 +1440,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 133, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L133" + "lineNumber": 133 }, "signature": [ "(obj: { fn: (foo: { param: string; }) => number; }) => () => { retFoo: () => string; }" @@ -1529,15 +1456,13 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 140, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L140" + "lineNumber": 140 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 89, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L89" + "lineNumber": 89 }, "lifecycle": "setup", "initialIsOpen": true @@ -1559,8 +1484,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 68, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L68" + "lineNumber": 68 }, "signature": [ "() => ", @@ -1576,8 +1500,7 @@ ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", - "lineNumber": 64, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L64" + "lineNumber": 64 }, "lifecycle": "start", "initialIsOpen": true @@ -1610,15 +1533,13 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts", - "lineNumber": 12, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L12" + "lineNumber": 12 } } ], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts", - "lineNumber": 11, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L11" + "lineNumber": 11 }, "initialIsOpen": false } diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json index 00fb2bd3aa7a9..a529d1a36657b 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json @@ -14,8 +14,7 @@ "label": "doTheFooFnThing", "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts", - "lineNumber": 9, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L9" + "lineNumber": 9 }, "tags": [], "returnComment": [], @@ -33,8 +32,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts", - "lineNumber": 11, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L11" + "lineNumber": 11 }, "signature": [ "() => \"foo\"" @@ -66,8 +64,7 @@ "description": [], "source": { "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts", - "lineNumber": 9, - "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts#L9" + "lineNumber": 9 }, "signature": [ "\"COMMON VAR!\"" diff --git a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx index f517565434c18..686a201761dcd 100644 --- a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx +++ b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx @@ -85,6 +85,7 @@ export function mountWithIntl( childContextTypes, ...props }: { + attachTo?: HTMLElement; context?: any; childContextTypes?: ValidationMap; } = {} diff --git a/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js b/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js index 43b6c90452b81..d472f27395ffb 100644 --- a/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js +++ b/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js @@ -12,9 +12,9 @@ import { get, toPath } from 'lodash'; import { Cluster } from '@kbn/es'; import { CI_PARALLEL_PROCESS_PREFIX } from '../ci_parallel_process_prefix'; import { esTestConfig } from './es_test_config'; +import { Client } from '@elastic/elasticsearch'; import { KIBANA_ROOT } from '../'; -import * as legacyElasticsearch from 'elasticsearch'; const path = require('path'); const del = require('del'); @@ -102,8 +102,8 @@ export function createLegacyEsTestCluster(options = {}) { * Returns an ES Client to the configured cluster */ getClient() { - return new legacyElasticsearch.Client({ - host: this.getUrl(), + return new Client({ + node: this.getUrl(), }); } diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index 7a996e98762ce..135884fbf13e7 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -9,6 +9,9 @@ const Path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); + const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/utils'); const webpack = require('webpack'); @@ -105,6 +108,28 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ }, optimization: { + minimizer: [ + new CssMinimizerPlugin({ + minimizerOptions: { + preset: [ + 'default', + { + discardComments: false, + }, + ], + }, + }), + new TerserPlugin({ + cache: false, + sourceMap: false, + extractComments: false, + parallel: false, + terserOptions: { + compress: true, + mangle: true, + }, + }), + ], noEmitOnErrors: true, splitChunks: { cacheGroups: { diff --git a/packages/kbn-utility-types/package.json b/packages/kbn-utility-types/package.json index a8f6e25276cec..33419ee0f1ec4 100644 --- a/packages/kbn-utility-types/package.json +++ b/packages/kbn-utility-types/package.json @@ -6,7 +6,7 @@ "main": "target", "types": "target/index.d.ts", "kibana": { - "devOnly": true + "devOnly": false }, "scripts": { "build": "../../node_modules/.bin/tsc", diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 13c16691bf12a..34b78bbd7e51e 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -68,6 +68,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { if (opts.ssl) { // @kbn/dev-utils is part of devDependencies + // eslint-disable-next-line import/no-extraneous-dependencies const { CA_CERT_PATH, KBN_KEY_PATH, KBN_CERT_PATH } = require('@kbn/dev-utils'); const customElasticsearchHosts = opts.elasticsearch ? opts.elasticsearch.split(',') diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 82a0419b1d0cf..00cc827a1e83f 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4050,54 +4050,74 @@ exports[`Header renders 1`] = ` hasArrow={true} id="headerHelpMenu" isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="m" repositionOnScroll={true} > -

-
- - -
+ +
- + @@ -4194,26 +4214,81 @@ exports[`Header renders 1`] = ` data-test-subj="toggleNavButton" onClick={[Function]} > - , + } + } className="euiHeaderSectionItem__button" + color="text" data-test-subj="toggleNavButton" onClick={[Function]} - type="button" > - - - - + + + + + + + + + + + diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6279d62d2c40e..ef3172b620b23 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -108,7 +108,9 @@ export class DocLinksService { sum: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-sum-aggregation.html`, top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, }, - runtimeFields: `${ELASTICSEARCH_DOCS}runtime.html`, + runtimeFields: { + mapping: `${ELASTICSEARCH_DOCS}runtime-mapping-fields.html`, + }, scriptedFields: { scriptFields: `${ELASTICSEARCH_DOCS}search-request-script-fields.html`, scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html`, @@ -191,6 +193,7 @@ export class DocLinksService { lens: `${ELASTIC_WEBSITE_URL}what-is/kibana-lens`, lensPanels: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/lens.html`, maps: `${ELASTIC_WEBSITE_URL}maps`, + vega: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/vega.html`, }, observability: { guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`, @@ -215,12 +218,15 @@ export class DocLinksService { guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/maps.html`, }, monitoring: { - alertsCluster: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/cluster-alerts.html`, alertsKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html`, alertsKibanaCpuThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-cpu-threshold`, alertsKibanaDiskThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-disk-usage-threshold`, alertsKibanaJvmThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-jvm-memory-threshold`, alertsKibanaMissingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`, + alertsKibanaThreadpoolRejections: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-thread-pool-rejections`, + alertsKibanaCCRReadExceptions: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-ccr-read-exceptions`, + alertsKibanaLargeShardSize: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-large-shard-size`, + alertsKibanaClusterAlerts: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-cluster-alerts`, metricbeatBlog: `${ELASTIC_WEBSITE_URL}blog/external-collection-for-elastic-stack-monitoring-is-now-available-via-metricbeat`, monitorElasticsearch: `${ELASTICSEARCH_DOCS}configuring-metricbeat.html`, monitorKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html`, @@ -231,7 +237,7 @@ export class DocLinksService { apiKeyServiceSettings: `${ELASTICSEARCH_DOCS}security-settings.html#api-key-service-settings`, clusterPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-cluster`, elasticsearchSettings: `${ELASTICSEARCH_DOCS}security-settings.html`, - elasticsearchEnableSecurity: `${ELASTICSEARCH_DOCS}get-started-enable-security.html`, + elasticsearchEnableSecurity: `${ELASTICSEARCH_DOCS}configuring-stack-security.html`, indicesPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-indices`, kibanaTLS: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`, kibanaPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-privileges.html`, @@ -284,6 +290,7 @@ export class DocLinksService { registerSourceOnly: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-source-only-repository`, registerUrl: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-read-only-repository`, restoreSnapshot: `${ELASTICSEARCH_DOCS}snapshots-restore-snapshot.html`, + restoreSnapshotApi: `${ELASTICSEARCH_DOCS}restore-snapshot-api.html#restore-snapshot-api-request-body`, }, ingest: { pipelines: `${ELASTICSEARCH_DOCS}ingest.html`, @@ -379,7 +386,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap index 7a1f936fe7f39..0d10ac47d0b75 100644 --- a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -26,7 +26,7 @@ Array [ ] `; -exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index d52cc090d5d19..19ebb5a9113c3 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -29,7 +29,7 @@ Array [ ] `; -exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"
Modal content
"`; +exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"
Modal content
"`; exports[`ModalService openConfirm() renders a string confirm message 1`] = ` Array [ @@ -49,7 +49,7 @@ Array [ ] `; -exports[`ModalService openConfirm() renders a string confirm message 2`] = `"

Some message

"`; +exports[`ModalService openConfirm() renders a string confirm message 2`] = `"

Some message

"`; exports[`ModalService openConfirm() with a currently active confirm replaces the current confirm with the new one 1`] = ` Array [ @@ -131,7 +131,7 @@ Array [ ] `; -exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
Modal content
"`; +exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
Modal content
"`; exports[`ModalService openModal() with a currently active confirm replaces the current confirm with the new one 1`] = ` Array [ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 5c034e68a3736..5a5ae253bac7f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -559,7 +559,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/src/core/server/elasticsearch/integration_tests/client.test.ts b/src/core/server/elasticsearch/integration_tests/client.test.ts new file mode 100644 index 0000000000000..3a4b7c5c4af22 --- /dev/null +++ b/src/core/server/elasticsearch/integration_tests/client.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 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 { + createTestServers, + TestElasticsearchUtils, + TestKibanaUtils, +} from '../../../test_helpers/kbn_server'; + +describe('elasticsearch clients', () => { + let esServer: TestElasticsearchUtils; + let kibanaServer: TestKibanaUtils; + + beforeAll(async () => { + const { startES, startKibana } = createTestServers({ + adjustTimeout: jest.setTimeout, + }); + + esServer = await startES(); + kibanaServer = await startKibana(); + }); + + afterAll(async () => { + await kibanaServer.stop(); + await esServer.stop(); + }); + + it('does not return deprecation warning when x-elastic-product-origin header is set', async () => { + // Header should be automatically set by Core + const resp1 = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' } + ); + expect(resp1.headers).not.toHaveProperty('warning'); + + // Also test setting it explicitly + const resp2 = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' }, + { headers: { 'x-elastic-product-origin': 'kibana' } } + ); + expect(resp2.headers).not.toHaveProperty('warning'); + }); + + it('returns deprecation warning when x-elastic-product-orign header is not set', async () => { + const resp = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' }, + { headers: { 'x-elastic-product-origin': null } } + ); + + expect(resp.headers).toHaveProperty('warning'); + expect(resp.headers!.warning).toMatch('system indices'); + }); +}); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 6c11534df0d11..af358caae8bfc 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -540,5 +540,50 @@ describe('http service', () => { expect(header['www-authenticate']).toEqual('Basic realm="Authorization Required"'); }); + + it('provides error reason for Elasticsearch Response Errors', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + // eslint-disable-next-line prefer-const + let elasticsearch: InternalElasticsearchServiceStart; + + esClient.ping.mockImplementation(() => + elasticsearchClientMock.createErrorTransportRequestPromise( + new ResponseError({ + statusCode: 404, + body: { + error: { + type: 'error_type', + reason: 'error_reason', + }, + }, + warnings: [], + headers: {}, + meta: {} as any, + }) + ) + ); + + const router = createRouter('/new-platform'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + try { + const result = await elasticsearch.client.asScoped(req).asInternalUser.ping(); + return res.ok({ + body: result, + }); + } catch (e) { + return res.badRequest({ + body: e, + }); + } + }); + + const coreStart = await root.start(); + elasticsearch = coreStart.elasticsearch; + + const { body } = await kbnTestServer.request.get(root, '/new-platform/').expect(400); + + expect(body.message).toEqual('[error_type]: error_reason'); + }); }); }); diff --git a/src/core/server/http/router/response_adapter.ts b/src/core/server/http/router/response_adapter.ts index 15c29e261c30b..32a66adc697cf 100644 --- a/src/core/server/http/router/response_adapter.ts +++ b/src/core/server/http/router/response_adapter.ts @@ -14,6 +14,8 @@ import typeDetect from 'type-detect'; import Boom from '@hapi/boom'; import * as stream from 'stream'; +import { isResponseError as isElasticsearchResponseError } from '../../elasticsearch/client/errors'; + import { HttpResponsePayload, KibanaResponse, @@ -147,6 +149,11 @@ function getErrorMessage(payload?: ResponseError): string { throw new Error('expected error message to be provided'); } if (typeof payload === 'string') return payload; + // for ES response errors include nested error reason message. it doesn't contain sensitive data. + if (isElasticsearchResponseError(payload)) { + return `[${payload.message}]: ${payload.meta.body?.error?.reason}`; + } + return getErrorMessage(payload.message); } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 37572c83e4c88..ce48e8dc9a317 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -23,6 +23,7 @@ import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; import { errors as EsErrors } from '@elastic/elasticsearch'; + const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -3654,6 +3655,33 @@ describe('SavedObjectsRepository', () => { ); }); + it(`uses the 'upsertAttributes' option when specified`, async () => { + const upsertAttributes = { + foo: 'bar', + hello: 'dolly', + }; + await incrementCounterSuccess(type, id, counterFields, { namespace, upsertAttributes }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + upsert: expect.objectContaining({ + [type]: { + foo: 'bar', + hello: 'dolly', + ...counterFields.reduce((aggs, field) => { + return { + ...aggs, + [field]: 1, + }; + }, {}), + }, + }), + }), + }), + expect.anything() + ); + }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, counterFields, { namespace }); expect(client.update).toHaveBeenCalledWith( diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index aa1e62c1652ca..6e2a1d6ec0511 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -76,10 +76,16 @@ import { // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Left = { tag: 'Left'; error: Record }; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Right = { tag: 'Right'; value: Record }; +interface Left { + tag: 'Left'; + error: Record; +} + +interface Right { + tag: 'Right'; + value: Record; +} + type Either = Left | Right; const isLeft = (either: Either): either is Left => either.tag === 'Left'; const isRight = (either: Either): either is Right => either.tag === 'Right'; @@ -98,7 +104,8 @@ export interface SavedObjectsRepositoryOptions { /** * @public */ -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsIncrementCounterOptions + extends SavedObjectsBaseOptions { /** * (default=false) If true, sets all the counter fields to 0 if they don't * already exist. Existing fields will be left as-is and won't be incremented. @@ -111,6 +118,10 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt * operation. See {@link MutatingOperationRefreshSetting} */ refresh?: MutatingOperationRefreshSetting; + /** + * Attributes to use when upserting the document if it doesn't exist. + */ + upsertAttributes?: Attributes; } /** @@ -1694,6 +1705,20 @@ export class SavedObjectsRepository { * .incrementCounter('dashboard_counter_type', 'counter_id', [ * 'stats.apiCalls', * ]) + * + * // Increment the apiCalls field counter by 4 + * repository + * .incrementCounter('dashboard_counter_type', 'counter_id', [ + * { fieldName: 'stats.apiCalls' incrementBy: 4 }, + * ]) + * + * // Initialize the document with arbitrary fields if not present + * repository.incrementCounter<{ appId: string }>( + * 'dashboard_counter_type', + * 'counter_id', + * [ 'stats.apiCalls'], + * { upsertAttributes: { appId: 'myId' } } + * ) * ``` * * @param type - The type of saved object whose fields should be incremented @@ -1706,7 +1731,7 @@ export class SavedObjectsRepository { type: string, id: string, counterFields: Array, - options: SavedObjectsIncrementCounterOptions = {} + options: SavedObjectsIncrementCounterOptions = {} ): Promise> { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); @@ -1728,12 +1753,16 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } - const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING, initialize = false } = options; + const { + migrationVersion, + refresh = DEFAULT_REFRESH_SETTING, + initialize = false, + upsertAttributes, + } = options; const normalizedCounterFields = counterFields.map((counterField) => { const fieldName = typeof counterField === 'string' ? counterField : counterField.fieldName; const incrementBy = typeof counterField === 'string' ? 1 : counterField.incrementBy || 1; - return { fieldName, incrementBy: initialize ? 0 : incrementBy, @@ -1757,11 +1786,14 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - attributes: normalizedCounterFields.reduce((acc, counterField) => { - const { fieldName, incrementBy } = counterField; - acc[fieldName] = incrementBy; - return acc; - }, {} as Record), + attributes: { + ...(upsertAttributes ?? {}), + ...normalizedCounterFields.reduce((acc, counterField) => { + const { fieldName, incrementBy } = counterField; + acc[fieldName] = incrementBy; + return acc; + }, {} as Record), + }, migrationVersion, updated_at: time, }); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 73f8a44075162..cf1647ef5cec3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2735,11 +2735,12 @@ export interface SavedObjectsIncrementCounterField { } // @public (undocumented) -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { initialize?: boolean; // (undocumented) migrationVersion?: SavedObjectsMigrationVersion; refresh?: MutatingOperationRefreshSetting; + upsertAttributes?: Attributes; } // @public @@ -2839,7 +2840,7 @@ export class SavedObjectsRepository { // (undocumented) find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; + incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; diff --git a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts index c67cd325572ff..96725d4405112 100644 --- a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts +++ b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts @@ -29,9 +29,6 @@ export function convertPanelStateToSavedDashboardPanel( panelState: DashboardPanelState, version: string ): SavedDashboardPanel { - const customTitle: string | undefined = panelState.explicitInput.title - ? (panelState.explicitInput.title as string) - : undefined; const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; return { version, @@ -39,7 +36,7 @@ export function convertPanelStateToSavedDashboardPanel( gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), - ...(customTitle && { title: customTitle }), + ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), ...(savedObjectId !== undefined && { id: savedObjectId }), }; } diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index a50aadc12e6c0..9e3018fb512c3 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -597,7 +597,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` restrictWidth="500px" >
@@ -232,12 +232,12 @@ exports[`after fetch hideWriteControls 1`] = ` restrictWidth={true} >
@@ -379,12 +379,12 @@ exports[`after fetch initialFilter 1`] = ` restrictWidth={true} >
@@ -525,12 +525,12 @@ exports[`after fetch renders all table rows 1`] = ` restrictWidth={true} >
@@ -671,12 +671,12 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` restrictWidth={true} >
@@ -817,12 +817,12 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` restrictWidth={true} >
diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index 145aaa64fa3ad..60e74a3fa126c 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -21,7 +21,6 @@ It is wired into the `TopNavMenu` component, but can be used independently. ### Fetch Query Suggestions The `getQuerySuggestions` function helps to construct a query. -KQL suggestion functions are registered in X-Pack, so this API does not return results in OSS. ```.ts diff --git a/src/plugins/data/common/search/session/types.ts b/src/plugins/data/common/search/session/types.ts index f63d2dfec142c..a7ba8ab9576b6 100644 --- a/src/plugins/data/common/search/session/types.ts +++ b/src/plugins/data/common/search/session/types.ts @@ -31,6 +31,13 @@ export interface SearchSessionSavedObjectAttributes { * Expiration time of the session. Expiration itself is managed by Elasticsearch. */ expires: string; + /** + * Time of transition into completed state, + * + * Can be "null" in case already completed session + * transitioned into in-progress session + */ + completed?: string | null; /** * status */ diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index 6b288c4507f06..eb9d859664c4d 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -18,6 +18,11 @@ import { import { ConfigSchema } from '../../config'; import { UsageCollectionSetup } from '../../../usage_collection/public'; import { createUsageCollector } from './collectors'; +import { + KUERY_LANGUAGE_NAME, + setupKqlQuerySuggestionProvider, +} from './providers/kql_query_suggestion'; +import { DataPublicPluginStart, DataStartDependencies } from '../types'; export class AutocompleteService { autocompleteConfig: ConfigSchema['autocomplete']; @@ -31,12 +36,6 @@ export class AutocompleteService { private readonly querySuggestionProviders: Map = new Map(); private getValueSuggestions?: ValueSuggestionsGetFn; - private addQuerySuggestionProvider = (language: string, provider: QuerySuggestionGetFn): void => { - if (language && provider && this.autocompleteConfig.querySuggestions.enabled) { - this.querySuggestionProviders.set(language, provider); - } - }; - private getQuerySuggestions: QuerySuggestionGetFn = (args) => { const { language } = args; const provider = this.querySuggestionProviders.get(language); @@ -50,7 +49,7 @@ export class AutocompleteService { /** @public **/ public setup( - core: CoreSetup, + core: CoreSetup, { timefilter, usageCollection, @@ -62,11 +61,15 @@ export class AutocompleteService { ? setupValueSuggestionProvider(core, { timefilter, usageCollector }) : getEmptyValueSuggestions; - return { - addQuerySuggestionProvider: this.addQuerySuggestionProvider, + if (this.autocompleteConfig.querySuggestions.enabled) { + this.querySuggestionProviders.set(KUERY_LANGUAGE_NAME, setupKqlQuerySuggestionProvider(core)); + } - /** @obsolete **/ - /** please use "getProvider" only from the start contract **/ + return { + /** + * @deprecated + * please use "getQuerySuggestions" from the start contract + */ getQuerySuggestions: this.getQuerySuggestions, }; } diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md new file mode 100644 index 0000000000000..2ab87a7a490c1 --- /dev/null +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md @@ -0,0 +1 @@ +This is implementation of KQL query suggestions diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json similarity index 100% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts index 5e562ae63e91b..c1c44f1f55548 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.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 { setupGetConjunctionSuggestions } from './conjunction'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx similarity index 67% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx index 7efc2ea193abe..345f9f8051e5d 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx @@ -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 React from 'react'; @@ -16,17 +17,17 @@ import { const bothArgumentsText = ( ); const oneOrMoreArgumentsText = ( ); @@ -34,20 +35,20 @@ const conjunctions: Record = { and: (

{bothArgumentsText}, }} description="Full text: ' Requires both arguments to be true'. See - 'xpack.data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." + 'data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." />

), or: (

= { ), }} description="Full text: 'Requires one or more arguments to be true'. See - 'xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." + 'data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." />

), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts similarity index 97% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts index afc55d13af9d9..f1eced06a33ea 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.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 indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx index ac6f7de888320..5cafca168dfa2 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx @@ -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 React from 'react'; @@ -22,7 +23,7 @@ const getDescription = (field: IFieldType) => { return (

{field.name} }} /> diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts index 8b36480a35b17..c5c1626ae74f6 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/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 { CoreSetup } from 'kibana/public'; @@ -17,6 +18,7 @@ import { QuerySuggestion, QuerySuggestionGetFnArgs, QuerySuggestionGetFn, + DataPublicPluginStart, } from '../../../../../../../src/plugins/data/public'; const cursorSymbol = '@kuery-cursor@'; @@ -26,7 +28,9 @@ const dedup = (suggestions: QuerySuggestion[]): QuerySuggestion[] => export const KUERY_LANGUAGE_NAME = 'kuery'; -export const setupKqlQuerySuggestionProvider = (core: CoreSetup): QuerySuggestionGetFn => { +export const setupKqlQuerySuggestionProvider = ( + core: CoreSetup +): QuerySuggestionGetFn => { const providers = { field: setupGetFieldSuggestions(core), value: setupGetValueSuggestions(core), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts index 0173617a99b1b..933449e779ef7 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.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 { escapeQuotes, escapeKuery } from './escape_kuery'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts index 901e61bde455d..54f03803a893e 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.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 { flow } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts similarity index 95% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts index bd021b0d0dac5..4debbc0843d51 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.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 indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx index cfe935e4b1990..618e33ddf345a 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx @@ -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 React from 'react'; @@ -15,44 +16,44 @@ import { QuerySuggestionTypes } from '../../../../../../../src/plugins/data/publ const equalsText = ( ); const lessThanOrEqualToText = ( ); const greaterThanOrEqualToText = ( ); const lessThanText = ( ); const greaterThanText = ( ); const existsText = ( ); @@ -60,11 +61,11 @@ const operators = { ':': { description: ( {equalsText} }} description="Full text: 'equals some value'. See - 'xpack.data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." + 'data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." /> ), fieldTypes: [ @@ -83,7 +84,7 @@ const operators = { '<=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -99,7 +100,7 @@ const operators = { '>=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -115,11 +116,11 @@ const operators = { '<': { description: ( {lessThanText} }} description="Full text: 'is less than some value'. See - 'xpack.data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." + 'data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -127,13 +128,13 @@ const operators = { '>': { description: ( {greaterThanText}, }} description="Full text: 'is greater than some value'. See - 'xpack.data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." + 'data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -141,11 +142,11 @@ const operators = { ': *': { description: ( {existsText} }} description="Full text: 'exists in any form'. See - 'xpack.data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." + 'data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." /> ), fieldTypes: undefined, diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts index aa236a45fa93c..f72fb75684105 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.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 { sortPrefixFirst } from './sort_prefix_first'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts similarity index 76% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts index c344197641ef4..25bc32d47f338 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.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 { partition } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts index b5abdbee51832..48e87a73f3671 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts @@ -1,17 +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 { CoreSetup } from 'kibana/public'; import { + DataPublicPluginStart, KueryNode, QuerySuggestionBasic, QuerySuggestionGetFnArgs, } from '../../../../../../../src/plugins/data/public'; export type KqlQuerySuggestionProvider = ( - core: CoreSetup + core: CoreSetup ) => (querySuggestionsGetFnArgs: QuerySuggestionGetFnArgs, kueryNode: KueryNode) => Promise; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts similarity index 93% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts index 5744dad43dcdd..c434d9a8ef365 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts @@ -1,15 +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 { setupGetValueSuggestions } from './value'; import indexPatternResponse from './__fixtures__/index_pattern_response.json'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { QuerySuggestionGetFnArgs, KueryNode } from '../../../../../../../src/plugins/data/public'; -import { setAutocompleteService } from '../../../services'; const mockKueryNode = (kueryNode: Partial) => (kueryNode as unknown) as KueryNode; @@ -19,11 +19,6 @@ describe('Kuery value suggestions', () => { let autocompleteServiceMock: any; beforeEach(() => { - getSuggestions = setupGetValueSuggestions(coreMock.createSetup()); - querySuggestionsArgs = ({ - indexPatterns: [indexPatternResponse], - } as unknown) as QuerySuggestionGetFnArgs; - autocompleteServiceMock = { getValueSuggestions: jest.fn(({ field }) => { let res: any[]; @@ -40,7 +35,16 @@ describe('Kuery value suggestions', () => { return Promise.resolve(res); }), }; - setAutocompleteService(autocompleteServiceMock); + + const coreSetup = coreMock.createSetup({ + pluginStartContract: { + autocomplete: autocompleteServiceMock, + }, + }); + getSuggestions = setupGetValueSuggestions(coreSetup); + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as QuerySuggestionGetFnArgs; jest.clearAllMocks(); }); diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts similarity index 79% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts index 92fd4d7b71bdc..f8fc9d165fc6b 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts @@ -1,15 +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. + * 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 { flatten } from 'lodash'; +import { CoreSetup } from 'kibana/public'; import { escapeQuotes } from './lib/escape_kuery'; import { KqlQuerySuggestionProvider } from './types'; -import { getAutocompleteService } from '../../../services'; import { + DataPublicPluginStart, IFieldType, IIndexPattern, QuerySuggestion, @@ -26,7 +28,12 @@ const wrapAsSuggestions = (start: number, end: number, query: string, values: st end, })); -export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { +export const setupGetValueSuggestions: KqlQuerySuggestionProvider = ( + core: CoreSetup +) => { + const autoCompleteServicePromise = core + .getStartServices() + .then(([_, __, dataStart]) => dataStart.autocomplete); return async ( { indexPatterns, boolFilter, useTimeRange, signal }, { start, end, prefix, suffix, fieldName, nestedPath } @@ -41,7 +48,7 @@ export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { }); const query = `${prefix}${suffix}`.trim(); - const { getValueSuggestions } = getAutocompleteService(); + const { getValueSuggestions } = await autoCompleteServicePromise; const data = await Promise.all( indexPatternFieldEntries.map(([indexPattern, field]) => diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index a3676c5116927..573820890de71 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -17,7 +17,6 @@ export type Setup = jest.Mocked>; export type Start = jest.Mocked>; const automcompleteSetupMock: jest.Mocked = { - addQuerySuggestionProvider: jest.fn(), getQuerySuggestions: jest.fn(), }; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4eae5629af3a6..e4085abe14050 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -23,7 +23,7 @@ import * as CSS from 'csstype'; import { Datatable as Datatable_2 } from 'src/plugins/expressions'; import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; -import { DatatableColumnType } from 'src/plugins/expressions/common'; +import { DatatableColumnType as DatatableColumnType_2 } from 'src/plugins/expressions/common'; import { DetailedPeerCertificate } from 'tls'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; @@ -85,8 +85,8 @@ import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common'; import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; -import { SavedObject } from 'kibana/server'; -import { SavedObject as SavedObject_2 } from 'src/core/server'; +import { SavedObject } from 'src/core/server'; +import { SavedObject as SavedObject_2 } from 'kibana/server'; import { SavedObjectReference } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectsFindOptions } from 'kibana/public'; @@ -188,7 +188,7 @@ export class AggConfig { // @deprecated (undocumented) toJSON(): AggConfigSerialized; // Warning: (ae-forgotten-export) The symbol "SerializableState" needs to be exported by the entry point index.d.ts - toSerializedFieldFormat(): {} | Ensure, SerializableState>; + toSerializedFieldFormat(): {} | Ensure, SerializableState_2>; // (undocumented) get type(): IAggType; set type(type: IAggType); @@ -272,9 +272,9 @@ export type AggConfigSerialized = Ensure<{ type: string; enabled?: boolean; id?: string; - params?: {} | SerializableState; + params?: {} | SerializableState_2; schema?: string; -}, SerializableState>; +}, SerializableState_2>; // Warning: (ae-missing-release-tag) "AggFunctionsMapping" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1604,7 +1604,7 @@ export class IndexPatternsService { // Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts // // (undocumented) - getCache: () => Promise[] | null | undefined>; + getCache: () => Promise[] | null | undefined>; getDefault: () => Promise; getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise; // Warning: (ae-forgotten-export) The symbol "GetFieldsOptions" needs to be exported by the entry point index.d.ts @@ -1616,7 +1616,7 @@ export class IndexPatternsService { }>>; getTitles: (refresh?: boolean) => Promise; refreshFields: (indexPattern: IndexPattern) => Promise; - savedObjectToSpec: (savedObject: SavedObject_2) => IndexPatternSpec; + savedObjectToSpec: (savedObject: SavedObject) => IndexPatternSpec; setDefault: (id: string, force?: boolean) => Promise; updateSavedObject(indexPattern: IndexPattern, saveAttempts?: number, ignoreErrors?: boolean): Promise; } @@ -2704,7 +2704,7 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:55:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index 8ee44cb2ca4ef..18d32463864e3 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { ISessionsClient } from './sessions_client'; import { ISessionService } from './session_service'; -import { SearchSessionState } from './search_session_state'; +import { SearchSessionState, SessionMeta } from './search_session_state'; export function getSessionsClientMock(): jest.Mocked { return { @@ -31,7 +31,9 @@ export function getSessionServiceMock(): jest.Mocked { getSessionId: jest.fn(), getSession$: jest.fn(() => new BehaviorSubject(undefined).asObservable()), state$: new BehaviorSubject(SearchSessionState.None).asObservable(), - searchSessionName$: new BehaviorSubject(undefined).asObservable(), + sessionMeta$: new BehaviorSubject({ + state: SearchSessionState.None, + }).asObservable(), renameCurrentSession: jest.fn(), trackSearch: jest.fn((searchDescriptor) => () => {}), destroy: jest.fn(), diff --git a/src/plugins/data/public/search/session/search_session_state.ts b/src/plugins/data/public/search/session/search_session_state.ts index e58e1062091bf..bf9036d361a8f 100644 --- a/src/plugins/data/public/search/session/search_session_state.ts +++ b/src/plugins/data/public/search/session/search_session_state.ts @@ -7,6 +7,7 @@ */ import uuid from 'uuid'; +import deepEqual from 'fast-deep-equal'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { createStateContainer, StateContainer } from '../../../../kibana_utils/public'; @@ -107,9 +108,19 @@ export interface SessionStateInternal { isCanceled: boolean; /** - * Start time of current session + * Start time of the current session (from browser perspective) */ startTime?: Date; + + /** + * Time when all the searches from the current session are completed (from browser perspective) + */ + completedTime?: Date; + + /** + * Time when the session was canceled by user, by hitting "stop" + */ + canceledTime?: Date; } const createSessionDefaultState: < @@ -170,12 +181,15 @@ export const sessionPureTransitions: SessionPureTransitions = { ...state, isStarted: true, pendingSearches: state.pendingSearches.concat(search), + completedTime: undefined, }; }, unTrackSearch: (state) => (search) => { + const pendingSearches = state.pendingSearches.filter((s) => s !== search); return { ...state, - pendingSearches: state.pendingSearches.filter((s) => s !== search), + pendingSearches, + completedTime: pendingSearches.length === 0 ? new Date() : state.completedTime, }; }, cancel: (state) => () => { @@ -185,6 +199,7 @@ export const sessionPureTransitions: SessionPureTransitions = { ...state, pendingSearches: [], isCanceled: true, + canceledTime: new Date(), isStored: false, searchSessionSavedObject: undefined, }; @@ -205,11 +220,24 @@ export const sessionPureTransitions: SessionPureTransitions = { }, }; +/** + * Consolidate meta info about current seach session + * Contains both deferred properties and plain properties from state + */ +export interface SessionMeta { + state: SearchSessionState; + name?: string; + startTime?: Date; + canceledTime?: Date; + completedTime?: Date; +} + export interface SessionPureSelectors< SearchDescriptor = unknown, S = SessionStateInternal > { getState: (state: S) => () => SearchSessionState; + getMeta: (state: S) => () => SessionMeta; } export const sessionPureSelectors: SessionPureSelectors = { @@ -233,6 +261,21 @@ export const sessionPureSelectors: SessionPureSelectors = { } return SearchSessionState.None; }, + getMeta(state) { + const sessionState = this.getState(state)(); + + return () => ({ + state: sessionState, + name: state.searchSessionSavedObject?.attributes.name, + startTime: state.searchSessionSavedObject?.attributes.created + ? new Date(state.searchSessionSavedObject?.attributes.created) + : state.startTime, + completedTime: state.searchSessionSavedObject?.attributes.completed + ? new Date(state.searchSessionSavedObject?.attributes.completed) + : state.completedTime, + canceledTime: state.canceledTime, + }); + }, }; export type SessionStateContainer = StateContainer< @@ -246,9 +289,7 @@ export const createSessionStateContainer = ( ): { stateContainer: SessionStateContainer; sessionState$: Observable; - sessionStartTime$: Observable; - searchSessionSavedObject$: Observable; - searchSessionName$: Observable; + sessionMeta$: Observable; } => { const stateContainer = createStateContainer( createSessionDefaultState(), @@ -257,33 +298,20 @@ export const createSessionStateContainer = ( freeze ? undefined : { freeze: (s) => s } ) as SessionStateContainer; - const sessionState$: Observable = stateContainer.state$.pipe( - map(() => stateContainer.selectors.getState()), - distinctUntilChanged(), - shareReplay(1) - ); - - const sessionStartTime$: Observable = stateContainer.state$.pipe( - map(() => stateContainer.get().startTime), - distinctUntilChanged(), - shareReplay(1) - ); - - const searchSessionSavedObject$ = stateContainer.state$.pipe( - map(() => stateContainer.get().searchSessionSavedObject), - distinctUntilChanged(), + const sessionMeta$: Observable = stateContainer.state$.pipe( + map(() => stateContainer.selectors.getMeta()), + distinctUntilChanged(deepEqual), shareReplay(1) ); - const searchSessionName$ = searchSessionSavedObject$.pipe( - map((savedObject) => savedObject?.attributes?.name) + const sessionState$: Observable = sessionMeta$.pipe( + map((meta) => meta.state), + distinctUntilChanged() ); return { stateContainer, sessionState$, - sessionStartTime$, - searchSessionSavedObject$, - searchSessionName$, + sessionMeta$, }; }; diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 785b9357fc895..381410574ecda 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { ConfigSchema } from '../../../config'; import { createSessionStateContainer, SearchSessionState, + SessionMeta, SessionStateContainer, } from './search_session_state'; import { ISessionsClient } from './sessions_client'; @@ -78,7 +79,7 @@ export class SessionService { public readonly state$: Observable; private readonly state: SessionStateContainer; - public readonly searchSessionName$: Observable; + public readonly sessionMeta$: Observable; private searchSessionInfoProvider?: SearchSessionInfoProvider; private searchSessionIndicatorUiConfig?: Partial; private subscription = new Subscription(); @@ -97,20 +98,24 @@ export class SessionService { const { stateContainer, sessionState$, - sessionStartTime$, - searchSessionName$, + sessionMeta$, } = createSessionStateContainer({ freeze: freezeState, }); this.state$ = sessionState$; this.state = stateContainer; - this.searchSessionName$ = searchSessionName$; + this.sessionMeta$ = sessionMeta$; this.subscription.add( - sessionStartTime$.subscribe((startTime) => { - if (startTime) this.nowProvider.set(startTime); - else this.nowProvider.reset(); - }) + sessionMeta$ + .pipe( + map((meta) => meta.startTime), + distinctUntilChanged() + ) + .subscribe((startTime) => { + if (startTime) this.nowProvider.set(startTime); + else this.nowProvider.reset(); + }) ); getStartServices().then(([coreStart]) => { diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index a7d1471af3a77..837cff41ccd6b 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -664,96 +664,87 @@ exports[`Inspector Data View component should render single table without select hasArrow={true} id="inspectorDownloadData" isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="none" repositionOnScroll={true} > -

-
- - - - - -
+ + + + +
- +
@@ -1304,81 +1295,72 @@ exports[`Inspector Data View component should render single table without select display="inlineBlock" hasArrow={true} isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="none" > -
-
- - - -
+ + + +
-
+
@@ -1420,7 +1402,7 @@ exports[`Inspector Data View component should render single table without select > - - + + + + - + @@ -2220,96 +2193,87 @@ exports[`Inspector Data View component should support multiple datatables 1`] = hasArrow={true} id="inspectorDownloadData" isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="none" repositionOnScroll={true} > -
-
- - - - - -
+ + + + +
-
+ @@ -2885,81 +2849,72 @@ exports[`Inspector Data View component should support multiple datatables 1`] = display="inlineBlock" hasArrow={true} isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="none" > -
-
- - - -
+ + + +
-
+ @@ -3001,7 +2956,7 @@ exports[`Inspector Data View component should support multiple datatables 1`] = > - - + + + + - + diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts index 1910ba054bf8e..f072f044925bf 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts @@ -12,15 +12,9 @@ export const ROLL_TOTAL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; /** - * Roll daily indices every 30 minutes. - * This means that, assuming a user can visit all the 44 apps we can possibly report - * in the 3 minutes interval the browser reports to the server, up to 22 users can have the same - * behaviour and we wouldn't need to paginate in the transactional documents (less than 10k docs). - * - * Based on a more normal expected use case, the users could visit up to 5 apps in those 3 minutes, - * allowing up to 200 users before reaching the limit. + * Roll daily indices every 24h */ -export const ROLL_DAILY_INDICES_INTERVAL = 30 * 60 * 1000; +export const ROLL_DAILY_INDICES_INTERVAL = 24 * 60 * 60 * 1000; /** * Start rolling indices after 5 minutes up diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts index 676f5fddc16e1..2d2d07d9d1894 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts @@ -7,3 +7,4 @@ */ export { registerApplicationUsageCollector } from './telemetry_application_usage_collector'; +export { rollDailyData as migrateTransactionalDocs } from './rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts similarity index 51% rename from src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts index 7d86bc41e0b90..5acd1fb9c9c3a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts @@ -6,21 +6,16 @@ * Side Public License, v 1. */ -import { rollDailyData, rollTotals } from './rollups'; -import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; -import { - SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../../core/server'; +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TRANSACTIONAL_TYPE } from '../saved_objects_types'; +import { rollDailyData } from './daily'; describe('rollDailyData', () => { const logger = loggingSystemMock.createLogger(); - test('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollDailyData(logger, undefined)).resolves.toBe(undefined); + test('returns false if no savedObjectsClient initialised yet', async () => { + await expect(rollDailyData(logger, undefined)).resolves.toBe(false); }); test('handle empty results', async () => { @@ -33,7 +28,7 @@ describe('rollDailyData', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(true); expect(savedObjectClient.get).not.toBeCalled(); expect(savedObjectClient.bulkCreate).not.toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); @@ -101,7 +96,7 @@ describe('rollDailyData', () => { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(true); expect(savedObjectClient.get).toHaveBeenCalledTimes(2); expect(savedObjectClient.get).toHaveBeenNthCalledWith( 1, @@ -196,7 +191,7 @@ describe('rollDailyData', () => { throw new Error('Something went terribly wrong'); }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(false); expect(savedObjectClient.get).toHaveBeenCalledTimes(1); expect(savedObjectClient.get).toHaveBeenCalledWith( SAVED_OBJECTS_DAILY_TYPE, @@ -206,185 +201,3 @@ describe('rollDailyData', () => { expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); }); }); - -describe('rollTotals', () => { - const logger = loggingSystemMock.createLogger(); - - test('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); - }); - - test('handle empty results', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { - switch (type) { - case SAVED_OBJECTS_DAILY_TYPE: - case SAVED_OBJECTS_TOTAL_TYPE: - return { saved_objects: [], total: 0, page, per_page: perPage }; - default: - throw new Error(`Unexpected type [${type}]`); - } - }); - await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); - }); - - test('migrate some documents', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { - switch (type) { - case SAVED_OBJECTS_DAILY_TYPE: - return { - saved_objects: [ - { - id: 'appId-2:2020-01-01', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-2', - timestamp: '2020-01-01T10:31:00.000Z', - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'appId-1:2020-01-01', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - timestamp: '2020-01-01T11:31:00.000Z', - minutesOnScreen: 2.5, - numberOfClicks: 2, - }, - }, - { - id: 'appId-1:2020-01-01:viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - timestamp: '2020-01-01T11:31:00.000Z', - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 3, - page, - per_page: perPage, - }; - case SAVED_OBJECTS_TOTAL_TYPE: - return { - saved_objects: [ - { - id: 'appId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'appId-1___viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - minutesOnScreen: 4, - numberOfClicks: 2, - }, - }, - { - id: 'appId-2___viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-2', - viewId: 'viewId-1', - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 3, - page, - per_page: perPage, - }; - default: - throw new Error(`Unexpected type [${type}]`); - } - }); - await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-1', - attributes: { - appId: 'appId-1', - viewId: MAIN_APP_DEFAULT_VIEW_ID, - minutesOnScreen: 3.0, - numberOfClicks: 3, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-1___viewId-1', - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - minutesOnScreen: 5.0, - numberOfClicks: 3, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-2___viewId-1', - attributes: { - appId: 'appId-2', - viewId: 'viewId-1', - minutesOnScreen: 1.0, - numberOfClicks: 1, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-2', - attributes: { - appId: 'appId-2', - viewId: MAIN_APP_DEFAULT_VIEW_ID, - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(3); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-2:2020-01-01' - ); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-1:2020-01-01' - ); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-1:2020-01-01:viewId-1' - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts similarity index 55% rename from src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts index df7e7662b49cf..a7873c7d5dfe9 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts @@ -6,18 +6,20 @@ * Side Public License, v 1. */ -import { ISavedObjectsRepository, SavedObject, Logger } from 'kibana/server'; import moment from 'moment'; +import type { Logger } from '@kbn/logging'; +import { + ISavedObjectsRepository, + SavedObject, + SavedObjectsErrorHelpers, +} from '../../../../../../core/server'; +import { getDailyId } from '../../../../../usage_collection/common/application_usage'; import { ApplicationUsageDaily, - ApplicationUsageTotal, ApplicationUsageTransactional, SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; +} from '../saved_objects_types'; /** * For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests) @@ -27,18 +29,17 @@ type ApplicationUsageDailyWithVersion = Pick< 'version' | 'attributes' >; -export function serializeKey(appId: string, viewId: string) { - return `${appId}___${viewId}`; -} - /** * Aggregates all the transactional events into daily aggregates * @param logger * @param savedObjectsClient */ -export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { +export async function rollDailyData( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +): Promise { if (!savedObjectsClient) { - return; + return false; } try { @@ -58,10 +59,7 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO } = doc; const dayId = moment(timestamp).format('YYYY-MM-DD'); - const dailyId = - !viewId || viewId === MAIN_APP_DEFAULT_VIEW_ID - ? `${appId}:${dayId}` - : `${appId}:${dayId}:${viewId}`; + const dailyId = getDailyId({ dayId, appId, viewId }); const existingDoc = toCreate.get(dailyId) || @@ -103,9 +101,11 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO } } } while (toCreate.size > 0); + return true; } catch (err) { logger.debug(`Failed to rollup transactional to daily entries`); logger.debug(err); + return false; } } @@ -125,7 +125,11 @@ async function getDailyDoc( dayId: string ): Promise { try { - return await savedObjectsClient.get(SAVED_OBJECTS_DAILY_TYPE, id); + const { attributes, version } = await savedObjectsClient.get( + SAVED_OBJECTS_DAILY_TYPE, + id + ); + return { attributes, version }; } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return { @@ -142,91 +146,3 @@ async function getDailyDoc( throw err; } } - -/** - * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days - * @param logger - * @param savedObjectsClient - */ -export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { - if (!savedObjectsClient) { - return; - } - - try { - const [ - { saved_objects: rawApplicationUsageTotals }, - { saved_objects: rawApplicationUsageDaily }, - ] = await Promise.all([ - savedObjectsClient.find({ - perPage: 10000, - type: SAVED_OBJECTS_TOTAL_TYPE, - }), - savedObjectsClient.find({ - perPage: 10000, - type: SAVED_OBJECTS_DAILY_TYPE, - filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, - }), - ]); - - const existingTotals = rawApplicationUsageTotals.reduce( - ( - acc, - { - attributes: { appId, viewId = MAIN_APP_DEFAULT_VIEW_ID, numberOfClicks, minutesOnScreen }, - } - ) => { - const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); - - return { - ...acc, - // No need to sum because there should be 1 document per appId only - [key]: { appId, viewId, numberOfClicks, minutesOnScreen }, - }; - }, - {} as Record< - string, - { appId: string; viewId: string; minutesOnScreen: number; numberOfClicks: number } - > - ); - - const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { - const { - appId, - viewId = MAIN_APP_DEFAULT_VIEW_ID, - numberOfClicks, - minutesOnScreen, - } = attributes; - const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); - const existing = acc[key] || { minutesOnScreen: 0, numberOfClicks: 0 }; - - return { - ...acc, - [key]: { - appId, - viewId, - numberOfClicks: numberOfClicks + existing.numberOfClicks, - minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, - }, - }; - }, existingTotals); - - await Promise.all([ - Object.entries(totals).length && - savedObjectsClient.bulkCreate( - Object.entries(totals).map(([id, entry]) => ({ - type: SAVED_OBJECTS_TOTAL_TYPE, - id, - attributes: entry, - })), - { overwrite: true } - ), - ...rawApplicationUsageDaily.map( - ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( - ), - ]); - } catch (err) { - logger.debug(`Failed to rollup daily entries to totals`); - logger.debug(err); - } -} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/index.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/index.ts new file mode 100644 index 0000000000000..8f3d83613aa9d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/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 { rollDailyData } from './daily'; +export { rollTotals } from './total'; +export { serializeKey } from './utils'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts new file mode 100644 index 0000000000000..9fea955ab5d8a --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright 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 { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../../usage_collection/common/constants'; +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE } from '../saved_objects_types'; +import { rollTotals } from './total'; + +describe('rollTotals', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + case SAVED_OBJECTS_TOTAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); + + test('migrate some documents', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId-2:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 2.5, + numberOfClicks: 2, + }, + }, + { + id: 'appId-1:2020-01-01:viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1, + numberOfClicks: 1, + }, + }, + ], + total: 3, + page, + per_page: perPage, + }; + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1___viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + minutesOnScreen: 4, + numberOfClicks: 2, + }, + }, + { + id: 'appId-2___viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + viewId: 'viewId-1', + minutesOnScreen: 1, + numberOfClicks: 1, + }, + }, + ], + total: 3, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1', + attributes: { + appId: 'appId-1', + viewId: MAIN_APP_DEFAULT_VIEW_ID, + minutesOnScreen: 3.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1___viewId-1', + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + minutesOnScreen: 5.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2___viewId-1', + attributes: { + appId: 'appId-2', + viewId: 'viewId-1', + minutesOnScreen: 1.0, + numberOfClicks: 1, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2', + attributes: { + appId: 'appId-2', + viewId: MAIN_APP_DEFAULT_VIEW_ID, + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(3); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-2:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01:viewId-1' + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts new file mode 100644 index 0000000000000..e27c7b897d995 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 type { Logger } from '@kbn/logging'; +import type { ISavedObjectsRepository } from 'kibana/server'; +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../../usage_collection/common/constants'; +import { + ApplicationUsageDaily, + ApplicationUsageTotal, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, +} from '../saved_objects_types'; +import { serializeKey } from './utils'; + +/** + * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days + * @param logger + * @param savedObjectsClient + */ +export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + ] = await Promise.all([ + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_TOTAL_TYPE, + }), + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, + }), + ]); + + const existingTotals = rawApplicationUsageTotals.reduce( + ( + acc, + { + attributes: { appId, viewId = MAIN_APP_DEFAULT_VIEW_ID, numberOfClicks, minutesOnScreen }, + } + ) => { + const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); + + return { + ...acc, + // No need to sum because there should be 1 document per appId only + [key]: { appId, viewId, numberOfClicks, minutesOnScreen }, + }; + }, + {} as Record< + string, + { appId: string; viewId: string; minutesOnScreen: number; numberOfClicks: number } + > + ); + + const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { + const { + appId, + viewId = MAIN_APP_DEFAULT_VIEW_ID, + numberOfClicks, + minutesOnScreen, + } = attributes; + const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); + const existing = acc[key] || { minutesOnScreen: 0, numberOfClicks: 0 }; + + return { + ...acc, + [key]: { + appId, + viewId, + numberOfClicks: numberOfClicks + existing.numberOfClicks, + minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, + }, + }; + }, existingTotals); + + await Promise.all([ + Object.entries(totals).length && + savedObjectsClient.bulkCreate( + Object.entries(totals).map(([id, entry]) => ({ + type: SAVED_OBJECTS_TOTAL_TYPE, + id, + attributes: entry, + })), + { overwrite: true } + ), + ...rawApplicationUsageDaily.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( + ), + ]); + } catch (err) { + logger.debug(`Failed to rollup daily entries to totals`); + logger.debug(err); + } +} diff --git a/src/plugins/vis_type_timeseries/common/field_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts similarity index 73% rename from src/plugins/vis_type_timeseries/common/field_types.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts index f9ebc83b4a5db..8be00e6287883 100644 --- a/src/plugins/vis_type_timeseries/common/field_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts @@ -6,10 +6,6 @@ * Side Public License, v 1. */ -export enum FIELD_TYPES { - BOOLEAN = 'boolean', - DATE = 'date', - GEO = 'geo_point', - NUMBER = 'number', - STRING = 'string', +export function serializeKey(appId: string, viewId: string) { + return `${appId}___${viewId}`; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 9e71b5c3b032e..f2b996f3af97a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; +import type { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; /** * Used for accumulating the totals of all the stats older than 90d @@ -17,6 +17,7 @@ export interface ApplicationUsageTotal extends SavedObjectAttributes { minutesOnScreen: number; numberOfClicks: number; } + export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; /** @@ -25,6 +26,8 @@ export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; export interface ApplicationUsageTransactional extends ApplicationUsageTotal { timestamp: string; } + +/** @deprecated transactional type is no longer used, and only preserved for backward compatibility */ export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; /** @@ -62,6 +65,7 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe }); // Type for storing ApplicationUsageTransactional (declaring empty mappings because we don't use the internal fields for query/aggregations) + // Remark: this type is deprecated and only here for BWC reasons. registerType({ name: SAVED_OBJECTS_TRANSACTIONAL_TYPE, hidden: false, diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 062d751ef454c..693e9132fe536 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -7,7 +7,7 @@ */ import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; -import { ApplicationUsageTelemetryReport } from './telemetry_application_usage_collector'; +import { ApplicationUsageTelemetryReport } from './types'; const commonSchema: MakeSchemaFrom = { appId: { type: 'keyword', _meta: { description: 'The application being tracked' } }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index 3e8434d446033..f1b21af5506e6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -11,74 +11,99 @@ import { Collector, createUsageCollectionSetupMock, } from '../../../../usage_collection/server/usage_collection.mock'; - +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; -import { ROLL_TOTAL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; import { registerApplicationUsageCollector, transformByApplicationViews, - ApplicationUsageViews, } from './telemetry_application_usage_collector'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { - SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; +import { ApplicationUsageViews } from './types'; -describe('telemetry_application_usage', () => { - jest.useFakeTimers(); +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE } from './saved_objects_types'; - const logger = loggingSystemMock.createLogger(); +// use fake timers to avoid triggering rollups during tests +jest.useFakeTimers(); +describe('telemetry_application_usage', () => { + let logger: ReturnType; let collector: Collector; + let usageCollectionMock: ReturnType; + let savedObjectClient: ReturnType; + let getSavedObjectClient: jest.MockedFunction<() => undefined | typeof savedObjectClient>; - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = new Collector(logger, config); - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - - const getUsageCollector = jest.fn(); const registerType = jest.fn(); const mockedFetchContext = createCollectorFetchContextMock(); - beforeAll(() => - registerApplicationUsageCollector(logger, usageCollectionMock, registerType, getUsageCollector) - ); - afterAll(() => jest.clearAllTimers()); + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + usageCollectionMock = createUsageCollectionSetupMock(); + savedObjectClient = savedObjectsRepositoryMock.create(); + getSavedObjectClient = jest.fn().mockReturnValue(savedObjectClient); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + registerApplicationUsageCollector( + logger, + usageCollectionMock, + registerType, + getSavedObjectClient + ); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); test('registered collector is set', () => { expect(collector).not.toBeUndefined(); }); test('if no savedObjectClient initialised, return undefined', async () => { + getSavedObjectClient.mockReturnValue(undefined); + expect(collector.isReady()).toBe(false); expect(await collector.fetch(mockedFetchContext)).toBeUndefined(); - jest.runTimersToTime(ROLL_INDICES_START); }); - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) + test('calls `savedObjectsClient.find` with the correct parameters', async () => { + savedObjectClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 20, + page: 0, + }); + + await collector.fetch(mockedFetchContext); + + expect(savedObjectClient.find).toHaveBeenCalledTimes(2); + + expect(savedObjectClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: SAVED_OBJECTS_TOTAL_TYPE, + }) + ); + expect(savedObjectClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: SAVED_OBJECTS_DAILY_TYPE, + }) ); - getUsageCollector.mockImplementation(() => savedObjectClient); + }); - jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + test('when savedObjectClient is initialised, return something', async () => { + savedObjectClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 20, + page: 0, + }); expect(collector.isReady()).toBe(true); expect(await collector.fetch(mockedFetchContext)).toStrictEqual({}); expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); }); - test('it only gets 10k even when there are more documents (ES limitation)', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - const total = 10000; + test('it aggregates total and daily data', async () => { savedObjectClient.find.mockImplementation(async (opts) => { switch (opts.type) { case SAVED_OBJECTS_TOTAL_TYPE: @@ -95,18 +120,6 @@ describe('telemetry_application_usage', () => { ], total: 1, } as any; - case SAVED_OBJECTS_TRANSACTIONAL_TYPE: - const doc = { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date().toISOString(), - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }; - const savedObjects = new Array(total).fill(doc); - return { saved_objects: savedObjects, total: total + 1 }; case SAVED_OBJECTS_DAILY_TYPE: return { saved_objects: [ @@ -125,122 +138,21 @@ describe('telemetry_application_usage', () => { } }); - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run - expect(await collector.fetch(mockedFetchContext)).toStrictEqual({ appId: { appId: 'appId', viewId: 'main', - clicks_total: total + 1 + 10, - clicks_7_days: total + 1, - clicks_30_days: total + 1, - clicks_90_days: total + 1, - minutes_on_screen_total: (total + 1) * 0.5 + 10, - minutes_on_screen_7_days: (total + 1) * 0.5, - minutes_on_screen_30_days: (total + 1) * 0.5, - minutes_on_screen_90_days: (total + 1) * 0.5, + clicks_total: 1 + 10, + clicks_7_days: 1, + clicks_30_days: 1, + clicks_90_days: 1, + minutes_on_screen_total: 0.5 + 10, + minutes_on_screen_7_days: 0.5, + minutes_on_screen_30_days: 0.5, + minutes_on_screen_90_days: 0.5, views: [], }, }); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - id: 'appId', - type: SAVED_OBJECTS_TOTAL_TYPE, - attributes: { - appId: 'appId', - viewId: 'main', - minutesOnScreen: 10.5, - numberOfClicks: 11, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId:YYYY-MM-DD' - ); - }); - - test('old transactional data not migrated yet', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async (opts) => { - switch (opts.type) { - case SAVED_OBJECTS_TOTAL_TYPE: - case SAVED_OBJECTS_DAILY_TYPE: - return { saved_objects: [], total: 0 } as any; - case SAVED_OBJECTS_TRANSACTIONAL_TYPE: - return { - saved_objects: [ - { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'test-id-2', - attributes: { - appId: 'appId', - viewId: 'main', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 2, - numberOfClicks: 2, - }, - }, - { - id: 'test-id-3', - attributes: { - appId: 'appId', - viewId: 'viewId-1', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 1, - }; - } - }); - - getUsageCollector.mockImplementation(() => savedObjectClient); - - expect(await collector.fetch(mockedFetchContext)).toStrictEqual({ - appId: { - appId: 'appId', - viewId: 'main', - clicks_total: 3, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 2.5, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - views: [ - { - appId: 'appId', - viewId: 'viewId-1', - clicks_total: 1, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 1, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }, - ], - }, - }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index ee1b42e61a6ca..a01f1bca4f0e0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -11,57 +11,21 @@ import { timer } from 'rxjs'; import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { serializeKey } from './rollups'; - import { ApplicationUsageDaily, ApplicationUsageTotal, - ApplicationUsageTransactional, registerMappings, SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, } from './saved_objects_types'; import { applicationUsageSchema } from './schema'; -import { rollDailyData, rollTotals } from './rollups'; +import { rollTotals, rollDailyData, serializeKey } from './rollups'; import { ROLL_TOTAL_INDICES_INTERVAL, ROLL_DAILY_INDICES_INTERVAL, ROLL_INDICES_START, } from './constants'; - -export interface ApplicationViewUsage { - appId: string; - viewId: string; - clicks_total: number; - clicks_7_days: number; - clicks_30_days: number; - clicks_90_days: number; - minutes_on_screen_total: number; - minutes_on_screen_7_days: number; - minutes_on_screen_30_days: number; - minutes_on_screen_90_days: number; -} - -export interface ApplicationUsageViews { - [serializedKey: string]: ApplicationViewUsage; -} - -export interface ApplicationUsageTelemetryReport { - [appId: string]: { - appId: string; - viewId: string; - clicks_total: number; - clicks_7_days: number; - clicks_30_days: number; - clicks_90_days: number; - minutes_on_screen_total: number; - minutes_on_screen_7_days: number; - minutes_on_screen_30_days: number; - minutes_on_screen_90_days: number; - views?: ApplicationViewUsage[]; - }; -} +import { ApplicationUsageTelemetryReport, ApplicationUsageViews } from './types'; export const transformByApplicationViews = ( report: ApplicationUsageViews @@ -92,6 +56,21 @@ export function registerApplicationUsageCollector( ) { registerMappings(registerType); + timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => + rollTotals(logger, getSavedObjectsClient()) + ); + + const dailyRollingSub = timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe( + async () => { + const success = await rollDailyData(logger, getSavedObjectsClient()); + // we only need to roll the transactional documents once to assure BWC + // once we rolling succeeds, we can stop. + if (success) { + dailyRollingSub.unsubscribe(); + } + } + ); + const collector = usageCollection.makeUsageCollector( { type: 'application_usage', @@ -105,7 +84,6 @@ export function registerApplicationUsageCollector( const [ { saved_objects: rawApplicationUsageTotals }, { saved_objects: rawApplicationUsageDaily }, - { saved_objects: rawApplicationUsageTransactional }, ] = await Promise.all([ savedObjectsClient.find({ type: SAVED_OBJECTS_TOTAL_TYPE, @@ -115,10 +93,6 @@ export function registerApplicationUsageCollector( type: SAVED_OBJECTS_DAILY_TYPE, perPage: 10000, // We can have up to 44 apps * 91 days = 4004 docs. This limit is OK }), - savedObjectsClient.find({ - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - perPage: 10000, // If we have more than those, we won't report the rest (they'll be rolled up to the daily soon enough to become a problem) - }), ]); const applicationUsageFromTotals = rawApplicationUsageTotals.reduce( @@ -156,10 +130,7 @@ export function registerApplicationUsageCollector( const nowMinus30 = moment().subtract(30, 'days'); const nowMinus90 = moment().subtract(90, 'days'); - const applicationUsage = [ - ...rawApplicationUsageDaily, - ...rawApplicationUsageTransactional, - ].reduce( + const applicationUsage = rawApplicationUsageDaily.reduce( ( acc, { @@ -224,11 +195,4 @@ export function registerApplicationUsageCollector( ); usageCollection.registerCollector(collector); - - timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => - rollDailyData(logger, getSavedObjectsClient()) - ); - timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => - rollTotals(logger, getSavedObjectsClient()) - ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.ts new file mode 100644 index 0000000000000..bef835e922d8d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.ts @@ -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 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 interface ApplicationViewUsage { + appId: string; + viewId: string; + clicks_total: number; + clicks_7_days: number; + clicks_30_days: number; + clicks_90_days: number; + minutes_on_screen_total: number; + minutes_on_screen_7_days: number; + minutes_on_screen_30_days: number; + minutes_on_screen_90_days: number; +} + +export interface ApplicationUsageViews { + [serializedKey: string]: ApplicationViewUsage; +} + +export interface ApplicationUsageTelemetryReport { + [appId: string]: { + appId: string; + viewId: string; + clicks_total: number; + clicks_7_days: number; + clicks_30_days: number; + clicks_90_days: number; + minutes_on_screen_total: number; + minutes_on_screen_7_days: number; + minutes_on_screen_30_days: number; + minutes_on_screen_90_days: number; + views?: ApplicationViewUsage[]; + }; +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 5959eb6aca4d4..41bb7c07bda7e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -412,4 +412,8 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableInspectEsQueries': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index fd63bb5bcaf43..c4a70f5065d8e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -31,6 +31,7 @@ export interface UsageStats { 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; 'observability:enableAlertingExperience': boolean; + 'observability:enableInspectEsQueries': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 67bbb46cfb607..bb426c91e827c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -60,7 +60,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` display="inlineBlock" hasArrow={true} isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="m" > ({ timelion: { save: true, + show: true, }, })); core.savedObjects.registerType(timelionSheetSavedObjectType); diff --git a/src/plugins/timelion/server/saved_objects/timelion_sheet.ts b/src/plugins/timelion/server/saved_objects/timelion_sheet.ts index 52d7f59a7c734..231e049280bb1 100644 --- a/src/plugins/timelion/server/saved_objects/timelion_sheet.ts +++ b/src/plugins/timelion/server/saved_objects/timelion_sheet.ts @@ -12,6 +12,20 @@ export const timelionSheetSavedObjectType: SavedObjectsType = { name: 'timelion-sheet', hidden: false, namespaceType: 'single', + management: { + icon: 'visTimelion', + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + getInAppUrl(obj) { + return { + path: `/app/timelion#/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'timelion.show', + }; + }, + }, mappings: { properties: { description: { type: 'text' }, diff --git a/src/plugins/usage_collection/common/application_usage.ts b/src/plugins/usage_collection/common/application_usage.ts new file mode 100644 index 0000000000000..c9dd489000d35 --- /dev/null +++ b/src/plugins/usage_collection/common/application_usage.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { MAIN_APP_DEFAULT_VIEW_ID } from './constants'; + +export const getDailyId = ({ + appId, + dayId, + viewId, +}: { + viewId: string; + appId: string; + dayId: string; +}) => { + return !viewId || viewId === MAIN_APP_DEFAULT_VIEW_ID + ? `${appId}:${dayId}` + : `${appId}:${dayId}:${viewId}`; +}; diff --git a/src/plugins/usage_collection/server/report/schema.ts b/src/plugins/usage_collection/server/report/schema.ts index 93203a33cd1e1..350ec8d90e765 100644 --- a/src/plugins/usage_collection/server/report/schema.ts +++ b/src/plugins/usage_collection/server/report/schema.ts @@ -9,6 +9,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { METRIC_TYPE } from '@kbn/analytics'; +const applicationUsageReportSchema = schema.object({ + minutesOnScreen: schema.number(), + numberOfClicks: schema.number(), + appId: schema.string(), + viewId: schema.string(), +}); + export const reportSchema = schema.object({ reportVersion: schema.maybe(schema.oneOf([schema.literal(3)])), userAgent: schema.maybe( @@ -38,17 +45,8 @@ export const reportSchema = schema.object({ }) ) ), - application_usage: schema.maybe( - schema.recordOf( - schema.string(), - schema.object({ - minutesOnScreen: schema.number(), - numberOfClicks: schema.number(), - appId: schema.string(), - viewId: schema.string(), - }) - ) - ), + application_usage: schema.maybe(schema.recordOf(schema.string(), applicationUsageReportSchema)), }); export type ReportSchemaType = TypeOf; +export type ApplicationUsageReport = TypeOf; diff --git a/src/plugins/usage_collection/server/report/store_application_usage.test.ts b/src/plugins/usage_collection/server/report/store_application_usage.test.ts new file mode 100644 index 0000000000000..c4c9e5746e6cb --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_application_usage.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 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 moment from 'moment'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; +import { getDailyId } from '../../common/application_usage'; +import { storeApplicationUsage } from './store_application_usage'; +import { ApplicationUsageReport } from './schema'; + +const createReport = (parts: Partial): ApplicationUsageReport => ({ + appId: 'appId', + viewId: 'viewId', + numberOfClicks: 0, + minutesOnScreen: 0, + ...parts, +}); + +describe('storeApplicationUsage', () => { + let repository: ReturnType; + let timestamp: Date; + + beforeEach(() => { + repository = savedObjectsRepositoryMock.create(); + timestamp = new Date(); + }); + + it('does not call `repository.incrementUsageCounters` when the report list is empty', async () => { + await storeApplicationUsage(repository, [], timestamp); + expect(repository.incrementCounter).not.toHaveBeenCalled(); + }); + + it('calls `repository.incrementUsageCounters` with the correct parameters', async () => { + const report = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 2, + minutesOnScreen: 5, + }); + + await storeApplicationUsage(repository, [report], timestamp); + + expect(repository.incrementCounter).toHaveBeenCalledTimes(1); + + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams(report, timestamp) + ); + }); + + it('aggregates reports with the same appId/viewId tuple', async () => { + const report1 = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 2, + minutesOnScreen: 5, + }); + const report2 = createReport({ + appId: 'app1', + viewId: 'view2', + numberOfClicks: 1, + minutesOnScreen: 7, + }); + const report3 = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 3, + minutesOnScreen: 9, + }); + + await storeApplicationUsage(repository, [report1, report2, report3], timestamp); + + expect(repository.incrementCounter).toHaveBeenCalledTimes(2); + + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams( + { + appId: 'app1', + viewId: 'view1', + numberOfClicks: report1.numberOfClicks + report3.numberOfClicks, + minutesOnScreen: report1.minutesOnScreen + report3.minutesOnScreen, + }, + timestamp + ) + ); + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams(report2, timestamp) + ); + }); +}); + +const expectedIncrementParams = ( + { appId, viewId, minutesOnScreen, numberOfClicks }: ApplicationUsageReport, + timestamp: Date +) => { + const dayId = moment(timestamp).format('YYYY-MM-DD'); + return [ + 'application_usage_daily', + getDailyId({ appId, viewId, dayId }), + [ + { fieldName: 'numberOfClicks', incrementBy: numberOfClicks }, + { fieldName: 'minutesOnScreen', incrementBy: minutesOnScreen }, + ], + { + upsertAttributes: { + appId, + viewId, + timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(), + }, + }, + ]; +}; diff --git a/src/plugins/usage_collection/server/report/store_application_usage.ts b/src/plugins/usage_collection/server/report/store_application_usage.ts new file mode 100644 index 0000000000000..2058b054fda8c --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_application_usage.ts @@ -0,0 +1,87 @@ +/* + * Copyright 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 moment from 'moment'; +import { Writable } from '@kbn/utility-types'; +import { ISavedObjectsRepository } from 'src/core/server'; +import { ApplicationUsageReport } from './schema'; +import { getDailyId } from '../../common/application_usage'; + +type WritableApplicationUsageReport = Writable; + +export const storeApplicationUsage = async ( + repository: ISavedObjectsRepository, + appUsages: ApplicationUsageReport[], + timestamp: Date +) => { + if (!appUsages.length) { + return; + } + + const dayId = getDayId(timestamp); + const aggregatedReports = aggregateAppUsages(appUsages); + + return Promise.allSettled( + aggregatedReports.map(async (report) => incrementUsageCounters(repository, report, dayId)) + ); +}; + +const aggregateAppUsages = (appUsages: ApplicationUsageReport[]) => { + return [ + ...appUsages + .reduce((map, appUsage) => { + const key = getKey(appUsage); + const aggregated: WritableApplicationUsageReport = map.get(key) ?? { + appId: appUsage.appId, + viewId: appUsage.viewId, + minutesOnScreen: 0, + numberOfClicks: 0, + }; + + aggregated.minutesOnScreen += appUsage.minutesOnScreen; + aggregated.numberOfClicks += appUsage.numberOfClicks; + + map.set(key, aggregated); + return map; + }, new Map()) + .values(), + ]; +}; + +const incrementUsageCounters = ( + repository: ISavedObjectsRepository, + { appId, viewId, numberOfClicks, minutesOnScreen }: WritableApplicationUsageReport, + dayId: string +) => { + const dailyId = getDailyId({ appId, viewId, dayId }); + + return repository.incrementCounter( + 'application_usage_daily', + dailyId, + [ + { fieldName: 'numberOfClicks', incrementBy: numberOfClicks }, + { fieldName: 'minutesOnScreen', incrementBy: minutesOnScreen }, + ], + { + upsertAttributes: { + appId, + viewId, + timestamp: getTimestamp(dayId), + }, + } + ); +}; + +const getKey = ({ viewId, appId }: ApplicationUsageReport) => `${appId}___${viewId}`; + +const getDayId = (timestamp: Date) => moment(timestamp).format('YYYY-MM-DD'); + +const getTimestamp = (dayId: string) => { + // Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects + return moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(); +}; diff --git a/src/plugins/usage_collection/server/report/store_report.test.mocks.ts b/src/plugins/usage_collection/server/report/store_report.test.mocks.ts new file mode 100644 index 0000000000000..d151e7d7a5ddd --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_report.test.mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 const storeApplicationUsageMock = jest.fn(); +jest.doMock('./store_application_usage', () => ({ + storeApplicationUsage: storeApplicationUsageMock, +})); diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index 7174a54067246..dfcdd1f8e7e42 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { storeApplicationUsageMock } from './store_report.test.mocks'; + import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { storeReport } from './store_report'; import { ReportSchemaType } from './schema'; @@ -16,8 +18,17 @@ describe('store_report', () => { const momentTimestamp = moment(); const date = momentTimestamp.format('DDMMYYYY'); + let repository: ReturnType; + + beforeEach(() => { + repository = savedObjectsRepositoryMock.create(); + }); + + afterEach(() => { + storeApplicationUsageMock.mockReset(); + }); + test('stores report for all types of data', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); const report: ReportSchemaType = { reportVersion: 3, userAgent: { @@ -53,9 +64,9 @@ describe('store_report', () => { }, }, }; - await storeReport(savedObjectClient, report); + await storeReport(repository, report); - expect(savedObjectClient.create).toHaveBeenCalledWith( + expect(repository.create).toHaveBeenCalledWith( 'ui-metric', { count: 1 }, { @@ -63,51 +74,45 @@ describe('store_report', () => { overwrite: true, } ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 1, 'ui-metric', 'test-app-name:test-event-name', [{ fieldName: 'count', incrementBy: 3 }] ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 2, 'ui-counter', `test-app-name:${date}:${METRIC_TYPE.LOADED}:test-event-name`, [{ fieldName: 'count', incrementBy: 1 }] ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 3, 'ui-counter', `test-app-name:${date}:${METRIC_TYPE.CLICK}:test-event-name`, [{ fieldName: 'count', incrementBy: 2 }] ); - expect(savedObjectClient.bulkCreate).toHaveBeenNthCalledWith(1, [ - { - type: 'application_usage_transactional', - attributes: { - numberOfClicks: 3, - minutesOnScreen: 10, - appId: 'appId', - viewId: 'appId_view', - timestamp: expect.any(Date), - }, - }, - ]); + + expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); + expect(storeApplicationUsageMock).toHaveBeenCalledWith( + repository, + Object.values(report.application_usage as Record), + expect.any(Date) + ); }); test('it should not fail if nothing to store', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); const report: ReportSchemaType = { reportVersion: 3, userAgent: void 0, uiCounter: void 0, application_usage: void 0, }; - await storeReport(savedObjectClient, report); + await storeReport(repository, report); - expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); - expect(savedObjectClient.incrementCounter).not.toHaveBeenCalled(); - expect(savedObjectClient.create).not.toHaveBeenCalled(); - expect(savedObjectClient.create).not.toHaveBeenCalled(); + expect(repository.bulkCreate).not.toHaveBeenCalled(); + expect(repository.incrementCounter).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts index c3e04990d5793..0545a54792d45 100644 --- a/src/plugins/usage_collection/server/report/store_report.ts +++ b/src/plugins/usage_collection/server/report/store_report.ts @@ -10,6 +10,7 @@ import { ISavedObjectsRepository } from 'src/core/server'; import moment from 'moment'; import { chain, sumBy } from 'lodash'; import { ReportSchemaType } from './schema'; +import { storeApplicationUsage } from './store_application_usage'; export async function storeReport( internalRepository: ISavedObjectsRepository, @@ -17,11 +18,11 @@ export async function storeReport( ) { const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : []; const userAgents = report.userAgent ? Object.entries(report.userAgent) : []; - const appUsage = report.application_usage ? Object.values(report.application_usage) : []; + const appUsages = report.application_usage ? Object.values(report.application_usage) : []; const momentTimestamp = moment(); - const timestamp = momentTimestamp.toDate(); const date = momentTimestamp.format('DDMMYYYY'); + const timestamp = momentTimestamp.toDate(); return Promise.allSettled([ // User Agent @@ -64,21 +65,6 @@ export async function storeReport( ]; }), // Application Usage - ...[ - (async () => { - if (!appUsage.length) return []; - const { saved_objects: savedObjects } = await internalRepository.bulkCreate( - appUsage.map((metric) => ({ - type: 'application_usage_transactional', - attributes: { - ...metric, - timestamp, - }, - })) - ); - - return savedObjects; - })(), - ], + storeApplicationUsage(internalRepository, appUsages, timestamp), ]); } diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts deleted file mode 100644 index c4da2085855e6..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { extractIndexPatterns } from './extract_index_patterns'; -import { PanelSchema } from './types'; - -describe('extractIndexPatterns(vis)', () => { - let panel: PanelSchema; - - beforeEach(() => { - panel = { - index_pattern: '*', - series: [ - { - override_index_pattern: 1, - series_index_pattern: 'example-1-*', - }, - { - override_index_pattern: 1, - series_index_pattern: 'example-2-*', - }, - ], - annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], - } as PanelSchema; - }); - - test('should return index patterns', () => { - expect(extractIndexPatterns(panel, '')).toEqual(['*', 'example-1-*', 'example-2-*', 'notes-*']); - }); -}); diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts deleted file mode 100644 index c716ae7abb821..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.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 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 { uniq } from 'lodash'; -import { PanelSchema } from '../common/types'; - -export function extractIndexPatterns( - panel: PanelSchema, - defaultIndex?: PanelSchema['default_index_pattern'] -) { - const patterns: string[] = []; - - if (panel.index_pattern) { - patterns.push(panel.index_pattern); - } - - panel.series.forEach((series) => { - const indexPattern = series.series_index_pattern; - if (indexPattern && series.override_index_pattern) { - patterns.push(indexPattern); - } - }); - - if (panel.annotations) { - panel.annotations.forEach((item) => { - const indexPattern = item.index_pattern; - if (indexPattern) { - patterns.push(indexPattern); - } - }); - } - - if (patterns.length === 0 && defaultIndex) { - patterns.push(defaultIndex); - } - - return uniq(patterns).sort(); -} diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.test.ts b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts new file mode 100644 index 0000000000000..d1036aab2dc3e --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 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 { toSanitizedFieldType } from './fields_utils'; +import type { FieldSpec, RuntimeField } from '../../data/common'; + +describe('fields_utils', () => { + describe('toSanitizedFieldType', () => { + const mockedField = { + lang: 'lang', + conflictDescriptions: {}, + aggregatable: true, + name: 'name', + type: 'type', + esTypes: ['long', 'geo'], + } as FieldSpec; + + test('should sanitize fields ', async () => { + const fields = [mockedField] as FieldSpec[]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` + Array [ + Object { + "label": "name", + "name": "name", + "type": "type", + }, + ] + `); + }); + + test('should filter runtime fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + runtimeField: {} as RuntimeField, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter non-aggregatable fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + aggregatable: false, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter nested fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + subType: { + nested: { + path: 'path', + }, + }, + }, + ]; + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.ts b/src/plugins/vis_type_timeseries/common/fields_utils.ts new file mode 100644 index 0000000000000..04499d5320ab8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { FieldSpec } from '../../data/common'; +import { isNestedField } from '../../data/common'; +import { SanitizedFieldType } from './types'; + +export const toSanitizedFieldType = (fields: FieldSpec[]) => { + return fields + .filter( + (field) => + // Make sure to only include mapped fields, e.g. no index pattern runtime fields + !field.runtimeField && field.aggregatable && !isNestedField(field) + ) + .map( + (field) => + ({ + name: field.name, + label: field.customLabel ?? field.name, + type: field.type, + } as SanitizedFieldType) + ); +}; diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts new file mode 100644 index 0000000000000..515fadffb6b32 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright 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 { + extractIndexPatternValues, + isStringTypeIndexPattern, + fetchIndexPattern, +} from './index_patterns_utils'; +import { PanelSchema } from './types'; +import { IndexPattern, IndexPatternsService } from '../../data/common'; + +describe('isStringTypeIndexPattern', () => { + test('should returns true on string-based index', () => { + expect(isStringTypeIndexPattern('index')).toBeTruthy(); + }); + test('should returns false on object-based index', () => { + expect(isStringTypeIndexPattern({ id: 'id' })).toBeFalsy(); + }); +}); + +describe('extractIndexPatterns', () => { + let panel: PanelSchema; + + beforeEach(() => { + panel = { + index_pattern: '*', + series: [ + { + override_index_pattern: 1, + series_index_pattern: 'example-1-*', + }, + { + override_index_pattern: 1, + series_index_pattern: 'example-2-*', + }, + ], + annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], + } as PanelSchema; + }); + + test('should return index patterns', () => { + expect(extractIndexPatternValues(panel, '')).toEqual([ + '*', + 'example-1-*', + 'example-2-*', + 'notes-*', + ]); + }); +}); + +describe('fetchIndexPattern', () => { + let mockedIndices: IndexPattern[] | []; + let indexPatternsService: IndexPatternsService; + + beforeEach(() => { + mockedIndices = []; + + indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + }); + + test('should return default index on no input value', async () => { + const value = await fetchIndexPattern('', indexPatternsService); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern({ id: 'indexId' }, indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts new file mode 100644 index 0000000000000..398d1c30ed5a7 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { uniq } from 'lodash'; +import { PanelSchema, IndexPatternValue, FetchedIndexPattern } from '../common/types'; +import { IndexPatternsService } from '../../data/common'; + +export const isStringTypeIndexPattern = ( + indexPatternValue: IndexPatternValue +): indexPatternValue is string => typeof indexPatternValue === 'string'; + +export const getIndexPatternKey = (indexPatternValue: IndexPatternValue) => + isStringTypeIndexPattern(indexPatternValue) ? indexPatternValue : indexPatternValue?.id ?? ''; + +export const extractIndexPatternValues = ( + panel: PanelSchema, + defaultIndex?: PanelSchema['default_index_pattern'] +) => { + const patterns: IndexPatternValue[] = []; + + if (panel.index_pattern) { + patterns.push(panel.index_pattern); + } + + panel.series.forEach((series) => { + const indexPattern = series.series_index_pattern; + if (indexPattern && series.override_index_pattern) { + patterns.push(indexPattern); + } + }); + + if (panel.annotations) { + panel.annotations.forEach((item) => { + const indexPattern = item.index_pattern; + if (indexPattern) { + patterns.push(indexPattern); + } + }); + } + + if (patterns.length === 0 && defaultIndex) { + patterns.push(defaultIndex); + } + + return uniq(patterns).sort(); +}; + +export const fetchIndexPattern = async ( + indexPatternValue: IndexPatternValue | undefined, + indexPatternsService: Pick +): Promise => { + let indexPattern: FetchedIndexPattern['indexPattern']; + let indexPatternString: string = ''; + + if (!indexPatternValue) { + indexPattern = await indexPatternsService.getDefault(); + } else { + if (isStringTypeIndexPattern(indexPatternValue)) { + indexPattern = (await indexPatternsService.find(indexPatternValue)).find( + (index) => index.title === indexPatternValue + ); + + if (!indexPattern) { + indexPatternString = indexPatternValue; + } + } else if (indexPatternValue.id) { + indexPattern = await indexPatternsService.get(indexPatternValue.id); + } + } + + return { + indexPattern, + indexPatternString: indexPattern?.title ?? indexPatternString, + }; +}; diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 7d93232f310c9..1fe6196ad545b 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -13,10 +13,12 @@ import { seriesItems, visPayloadSchema, fieldObject, + indexPattern, annotationsItems, } from './vis_schema'; import { PANEL_TYPES } from './panel_types'; import { TimeseriesUIRestrictions } from './ui_restrictions'; +import { IndexPattern } from '../../data/common'; export type AnnotationItemsSchema = TypeOf; export type SeriesItemsSchema = TypeOf; @@ -24,6 +26,12 @@ export type MetricsItemsSchema = TypeOf; export type PanelSchema = TypeOf; export type VisPayload = TypeOf; export type FieldObject = TypeOf; +export type IndexPatternValue = TypeOf; + +export interface FetchedIndexPattern { + indexPattern: IndexPattern | undefined | null; + indexPatternString: string | undefined; +} export interface PanelData { id: string; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index a6bf70948bc1b..297b021fa9e77 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -28,7 +28,7 @@ const numberOptional = schema.maybe(schema.number()); const queryObject = schema.object({ language: schema.string(), - query: schema.string(), + query: schema.oneOf([schema.string(), schema.any()]), }); const stringOrNumberOptionalNullable = schema.nullable( schema.oneOf([stringOptionalNullable, numberOptional]) @@ -37,6 +37,13 @@ const numberOptionalOrEmptyString = schema.maybe( schema.oneOf([numberOptional, schema.literal('')]) ); +export const indexPattern = schema.oneOf([ + schema.maybe(schema.string()), + schema.object({ + id: schema.string(), + }), +]); + export const fieldObject = stringOptionalNullable; export const annotationsItems = schema.object({ @@ -47,7 +54,7 @@ export const annotationsItems = schema.object({ id: schema.string(), ignore_global_filters: numberIntegerOptional, ignore_panel_filters: numberIntegerOptional, - index_pattern: stringOptionalNullable, + index_pattern: indexPattern, query_string: schema.maybe(queryObject), template: stringOptionalNullable, time_field: fieldObject, @@ -68,6 +75,7 @@ const gaugeColorRulesItems = schema.object({ operator: stringOptionalNullable, value: schema.maybe(schema.nullable(schema.number())), }); + export const metricsItems = schema.object({ field: fieldObject, id: stringRequired, @@ -167,7 +175,7 @@ export const seriesItems = schema.object({ point_size: numberOptionalOrEmptyString, separate_axis: numberIntegerOptional, seperate_axis: numberIntegerOptional, - series_index_pattern: stringOptionalNullable, + series_index_pattern: indexPattern, series_max_bars: numberIntegerOptional, series_time_field: fieldObject, series_interval: stringOptionalNullable, @@ -195,6 +203,7 @@ export const seriesItems = schema.object({ }); export const panel = schema.object({ + use_kibana_indexes: schema.maybe(schema.boolean()), annotations: schema.maybe(schema.arrayOf(annotationsItems)), axis_formatter: stringRequired, axis_position: stringRequired, @@ -218,7 +227,7 @@ export const panel = schema.object({ id: stringRequired, ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, - index_pattern: stringRequired, + index_pattern: indexPattern, max_bars: numberIntegerOptional, interval: stringRequired, isModelInvalid: schema.maybe(schema.boolean()), diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index aa5eac84663ad..242b62a2c5ee4 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "visualize"], + "optionalPlugins": ["usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx index 4fc7b89e23765..82989cc15d6c9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx @@ -10,8 +10,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiComboBox, EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui'; import { METRIC_TYPES } from '../../../../common/metric_types'; - -import type { SanitizedFieldType } from '../../../../common/types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; +import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types'; import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; // @ts-ignore @@ -20,7 +20,7 @@ import { isFieldEnabled } from '../../lib/check_ui_restrictions'; interface FieldSelectProps { type: string; fields: Record; - indexPattern: string; + indexPattern: IndexPatternValue; value?: string | null; onChange: (options: Array>) => void; disabled?: boolean; @@ -62,8 +62,10 @@ export function FieldSelect({ const selectedOptions: Array> = []; let newPlaceholder = placeholder; + const fieldsSelector = getIndexPatternKey(indexPattern); + const groupedOptions: EuiComboBoxProps['options'] = Object.values( - (fields[indexPattern] || []).reduce>>( + (fields[fieldsSelector] || []).reduce>>( (acc, field) => { if (placeholder === field?.name) { newPlaceholder = field.label ?? field.name; diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js index f95eeb4816128..ab0db6daae18a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js @@ -32,8 +32,8 @@ import { EuiCode, EuiText, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPatternSelect } from './lib/index_pattern_select'; function newAnnotation() { return { @@ -91,7 +91,6 @@ export class AnnotationsEditor extends Component { const htmlId = htmlIdGenerator(model.id); const handleAdd = collectionActions.handleAdd.bind(null, this.props, newAnnotation); const handleDelete = collectionActions.handleDelete.bind(null, this.props, model); - const defaultIndexPattern = this.props.model.default_index_pattern; return (
@@ -108,30 +107,11 @@ export class AnnotationsEditor extends Component { - - } - helpText={ - defaultIndexPattern && - !model.index_pattern && - i18n.translate('visTypeTimeseries.annotationsEditor.searchByDefaultIndex', { - defaultMessage: 'Default index pattern is used. To query all indexes use *', - }) - } - fullWidth - > - - + { const config = getUISettings(); const timeFieldName = `${prefix}time_field`; @@ -89,13 +91,6 @@ export const IndexPattern = ({ const handleTextChange = createTextHandler(onChange); const timeRangeOptions = [ - { - label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.lastValue', { - defaultMessage: 'Last value', - }), - value: TIME_RANGE_DATA_MODES.LAST_VALUE, - disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.LAST_VALUE, uiRestrictions), - }, { label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.entireTimeRange', { defaultMessage: 'Entire time range', @@ -103,6 +98,13 @@ export const IndexPattern = ({ value: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, uiRestrictions), }, + { + label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.lastValue', { + defaultMessage: 'Last value', + }), + value: TIME_RANGE_DATA_MODES.LAST_VALUE, + disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.LAST_VALUE, uiRestrictions), + }, ]; const defaults = { @@ -139,6 +141,7 @@ export const IndexPattern = ({ })} > - - - + - {allowLevelofDetail && ( + {allowLevelOfDetail && ( >; + +/** @internal **/ +type SelectedOptions = EuiComboBoxProps['selectedOptions']; + +const toComboBoxOptions = (options: IdsWithTitle) => + options.map(({ title, id }) => ({ label: title, id })); + +export const ComboBoxSelect = ({ + fetchedIndex, + onIndexChange, + onModeChange, + disabled, + placeholder, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [availableIndexes, setAvailableIndexes] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + + const onComboBoxChange: EuiComboBoxProps['onChange'] = useCallback( + ([selected]) => { + onIndexChange(selected ? { id: selected.id } : ''); + }, + [onIndexChange] + ); + + useEffect(() => { + let options: SelectedOptions = []; + const { indexPattern, indexPatternString } = fetchedIndex; + + if (indexPattern || indexPatternString) { + if (!indexPattern) { + options = [{ label: indexPatternString ?? '' }]; + } else { + options = [ + { + id: indexPattern.id, + label: indexPattern.title, + }, + ]; + } + } + setSelectedOptions(options); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndexes() { + setAvailableIndexes(await getDataStart().indexPatterns.getIdsWithTitle()); + } + + fetchIndexes(); + }, []); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx new file mode 100644 index 0000000000000..86d1758932301 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 React, { useCallback, useState, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; +import { SwitchModePopover } from './switch_mode_popover'; + +import type { SelectIndexComponentProps } from './types'; + +export const FieldTextSelect = ({ + fetchedIndex, + onIndexChange, + disabled, + placeholder, + onModeChange, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [inputValue, setInputValue] = useState(); + const { indexPatternString } = fetchedIndex; + + const onFieldTextChange: EuiFieldTextProps['onChange'] = useCallback((e) => { + setInputValue(e.target.value); + }, []); + + useEffect(() => { + if (inputValue === undefined) { + setInputValue(indexPatternString ?? ''); + } + }, [indexPatternString, inputValue]); + + useDebounce( + () => { + if (inputValue !== indexPatternString) { + onIndexChange(inputValue); + } + }, + 150, + [inputValue, onIndexChange] + ); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts new file mode 100644 index 0000000000000..584f13e7a025b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { IndexPatternSelect } from './index_pattern_select'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx new file mode 100644 index 0000000000000..28b9c173a2b1b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 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 React, { useState, useContext, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiFormRow, EuiText, EuiLink, htmlIdGenerator } from '@elastic/eui'; +import { getCoreStart, getDataStart } from '../../../../services'; +import { PanelModelContext } from '../../../contexts/panel_model_context'; + +import { + isStringTypeIndexPattern, + fetchIndexPattern, +} from '../../../../../common/index_patterns_utils'; + +import { FieldTextSelect } from './field_text_select'; +import { ComboBoxSelect } from './combo_box_select'; + +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; + +const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; + +interface IndexPatternSelectProps { + value: IndexPatternValue; + indexPatternName: string; + onChange: Function; + disabled?: boolean; + allowIndexSwitchingMode?: boolean; +} + +const defaultIndexPatternHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.defaultIndexPatternText', + { + defaultMessage: 'Default index pattern is used.', + } +); + +const queryAllIndexesHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.queryAllIndexesText', + { + defaultMessage: 'To query all indexes use *', + } +); + +const indexPatternLabel = i18n.translate('visTypeTimeseries.indexPatternSelect.label', { + defaultMessage: 'Index pattern', +}); + +export const IndexPatternSelect = ({ + value, + indexPatternName, + onChange, + disabled, + allowIndexSwitchingMode, +}: IndexPatternSelectProps) => { + const htmlId = htmlIdGenerator(); + const panelModel = useContext(PanelModelContext); + const [fetchedIndex, setFetchedIndex] = useState(); + const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); + const Component = useKibanaIndices ? ComboBoxSelect : FieldTextSelect; + + const onIndexChange = useCallback( + (index: IndexPatternValue) => { + onChange({ + [indexPatternName]: index, + }); + }, + [indexPatternName, onChange] + ); + + const onModeChange = useCallback( + (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => { + onChange({ + [USE_KIBANA_INDEXES_KEY]: useKibanaIndexes, + [indexPatternName]: index?.indexPattern?.id + ? { + id: index.indexPattern.id, + } + : '', + }); + }, + [onChange, indexPatternName] + ); + + const navigateToCreateIndexPatternPage = useCallback(() => { + const coreStart = getCoreStart(); + + coreStart.application.navigateToApp('management', { + path: `/kibana/indexPatterns/create?name=${fetchedIndex!.indexPatternString ?? ''}`, + }); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndex() { + const { indexPatterns } = getDataStart(); + + setFetchedIndex( + value + ? await fetchIndexPattern(value, indexPatterns) + : { + indexPattern: undefined, + indexPatternString: undefined, + } + ); + } + + fetchIndex(); + }, [value]); + + if (!fetchedIndex) { + return null; + } + + return ( + + + + + + ) : null + } + > + + + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx new file mode 100644 index 0000000000000..5f5506ce4a332 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.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 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 React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonIcon, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; + +import type { PopoverProps } from './types'; + +export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onButtonClick = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []); + + const switchMode = useCallback(() => { + onModeChange(!useKibanaIndices); + }, [onModeChange, useKibanaIndices]); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + style={{ height: 'auto' }} + > +
+ + {i18n.translate('visTypeTimeseries.indexPatternSelect.switchModePopover.title', { + defaultMessage: 'Index pattern selection mode', + })} + + + + + + +
+
+ ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts new file mode 100644 index 0000000000000..93b15402e3c24 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 type { Assign } from '@kbn/utility-types'; +import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; + +/** @internal **/ +export interface SelectIndexComponentProps { + fetchedIndex: FetchedIndexPattern; + onIndexChange: (value: IndexPatternValue) => void; + onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; + 'data-test-subj': string; + placeholder?: string; + disabled?: boolean; + allowSwitchMode?: boolean; +} + +/** @internal **/ +export type PopoverProps = Assign< + Pick, + { + useKibanaIndices: boolean; + } +>; diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx index e302bbb9adb0b..f39ff6923f5ce 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx @@ -29,12 +29,11 @@ import type { Writable } from '@kbn/utility-types'; // @ts-ignore import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { createSelectHandler } from '../lib/create_select_handler'; import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { YesNo } from '../yes_no'; @@ -128,6 +127,7 @@ export class GaugePanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -149,10 +149,10 @@ export class GaugePanelConfig extends Component< language: model.filter?.language || getDefaultQueryLanguage(), query: model.filter?.query || '', }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
@@ -321,6 +321,7 @@ export class GaugePanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="gaugeEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="gaugeEditorPanelOptionsBtn" > @@ -161,13 +161,13 @@ export class MarkdownPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx index ec11f94d245a0..3ab49c1bef873 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx @@ -25,12 +25,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { ColorRules } from '../color_rules'; import { YesNo } from '../yes_no'; - -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { limitOfSeries } from '../../../../common/ui_restrictions'; @@ -93,6 +91,7 @@ export class MetricPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -111,13 +110,13 @@ export class MetricPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -166,6 +165,7 @@ export class MetricPanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="metricEditorDataBtn" > - -
- -
-
+ + +
+ +
+
+
); } diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 20e07be4e3fa4..f3d01df19666a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -31,16 +31,17 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FieldSelect } from '../aggs/field_select'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { YesNo } from '../yes_no'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging + import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { VisDataContext } from '../../contexts/vis_data_context'; import { BUCKET_TYPES } from '../../../../common/metric_types'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export class TablePanelConfig extends Component< PanelConfigProps, @@ -66,7 +67,7 @@ export class TablePanelConfig extends Component< handlePivotChange = (selectedOption: Array>) => { const { fields, model } = this.props; const pivotId = get(selectedOption, '[0].value', null); - const field = fields[model.index_pattern].find((f) => f.name === pivotId); + const field = fields[getIndexPatternKey(model.index_pattern)].find((f) => f.name === pivotId); const pivotType = get(field, 'type', model.pivot_type); this.props.onChange({ @@ -237,15 +238,13 @@ export class TablePanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -274,6 +273,7 @@ export class TablePanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="tableEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="tableEditorPanelOptionsBtn" > - @@ -202,13 +201,13 @@ export class TimeseriesPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx index 184063f88ef03..78ac11eb39744 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx @@ -33,7 +33,6 @@ import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; import { YesNo } from '../yes_no'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; @@ -120,6 +119,7 @@ export class TopNPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -138,13 +138,13 @@ export class TopNPanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter: PanelConfigProps['model']['filter']) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -225,6 +225,7 @@ export class TopNPanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="topNEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="topNEditorPanelOptionsBtn" > & { + indexPatterns: IndexPatternValue[]; +}; + +export function QueryBarWrapper({ query, onChange, indexPatterns }: QueryBarWrapperProps) { + const { indexPatterns: indexPatternsService } = getDataStart(); + const [indexes, setIndexes] = useState([]); + + const coreStartContext = useContext(CoreStartContext); + + useEffect(() => { + async function fetchIndexes() { + const i: QueryStringInputProps['indexPatterns'] = []; + + for (const index of indexPatterns ?? []) { + if (isStringTypeIndexPattern(index)) { + i.push(index); + } else if (index?.id) { + const fetchedIndex = await fetchIndexPattern(index, indexPatternsService); + + if (fetchedIndex.indexPattern) { + i.push(fetchedIndex.indexPattern); + } + } + } + setIndexes(i); + } + + fetchIndexes(); + }, [indexPatterns, indexPatternsService]); + + return ( + + ); +} diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 4e48ed4406ea5..3185503acb569 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -137,5 +137,5 @@ SeriesConfig.propTypes = { panel: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js index 0b67d52c23cd2..950101103b3a5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js @@ -90,5 +90,5 @@ SeriesConfigQueryBarWithIgnoreGlobalFilter.propTypes = { onChange: PropTypes.func, model: PropTypes.object, panel: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js index 5891320aa684f..b996abd6373ab 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js @@ -25,7 +25,7 @@ import { EuiFieldText, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { FIELD_TYPES } from '../../../../common/field_types'; +import { KBN_FIELD_TYPES } from '../../../../../data/public'; import { STACKED_OPTIONS } from '../../visualizations/constants'; const DEFAULTS = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' }; @@ -133,7 +133,7 @@ export const SplitByTermsUI = ({ - {selectedFieldType === FIELD_TYPES.STRING && ( + {selectedFieldType === KBN_FIELD_TYPES.STRING && ( { + abortableFetchFields = (extractedIndexPatterns: IndexPatternValue[]) => { this.abortControllerFetchFields?.abort(); this.abortControllerFetchFields = new AbortController(); @@ -202,7 +213,7 @@ export class VisEditor extends Component { const defaultIndexTitle = index?.title ?? ''; - const indexPatterns = extractIndexPatterns(this.props.vis.params, defaultIndexTitle); + const indexPatterns = extractIndexPatternValues(this.props.vis.params, defaultIndexTitle); const visFields = await fetchFields(indexPatterns); this.setState((state) => ({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js index 2909167031d08..46cc8b6ebe635 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js @@ -198,7 +198,7 @@ GaugeSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const GaugeSeries = injectI18n(GaugeSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js index 6f00abe5aa2c0..f9817242a101a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js @@ -200,7 +200,7 @@ MarkdownSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MarkdownSeries = injectI18n(MarkdownSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js index 64425cf534226..5ec2378792812 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js @@ -211,7 +211,7 @@ MetricSeriesUi.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MetricSeries = injectI18n(MetricSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js index fecd6cde1dca8..0ba8d3e855365 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js @@ -9,6 +9,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; + import { DataFormatPicker } from '../../data_format_picker'; import { createSelectHandler } from '../../lib/create_select_handler'; import { createTextHandler } from '../../lib/create_text_handler'; @@ -28,11 +30,11 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { getDefaultQueryLanguage } from '../../lib/get_default_query_language'; - import { QueryBarWrapper } from '../../query_bar_wrapper'; -class TableSeriesConfigUI extends Component { + +export class TableSeriesConfig extends Component { UNSAFE_componentWillMount() { const { model } = this.props; if (!model.color_rules || (model.color_rules && model.color_rules.length === 0)) { @@ -48,68 +50,58 @@ class TableSeriesConfigUI extends Component { const handleSelectChange = createSelectHandler(this.props.onChange); const handleTextChange = createTextHandler(this.props.onChange); const htmlId = htmlIdGenerator(); - const { intl } = this.props; const functionOptions = [ { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.sumLabel', + label: i18n.translate('visTypeTimeseries.table.sumLabel', { defaultMessage: 'Sum', }), value: 'sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.maxLabel', + label: i18n.translate('visTypeTimeseries.table.maxLabel', { defaultMessage: 'Max', }), value: 'max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.minLabel', + label: i18n.translate('visTypeTimeseries.table.minLabel', { defaultMessage: 'Min', }), value: 'min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.avgLabel', + label: i18n.translate('visTypeTimeseries.table.avgLabel', { defaultMessage: 'Avg', }), value: 'mean', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallSumLabel', + label: i18n.translate('visTypeTimeseries.table.overallSumLabel', { defaultMessage: 'Overall Sum', }), value: 'overall_sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMaxLabel', + label: i18n.translate('visTypeTimeseries.table.overallMaxLabel', { defaultMessage: 'Overall Max', }), value: 'overall_max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMinLabel', + label: i18n.translate('visTypeTimeseries.table.overallMinLabel', { defaultMessage: 'Overall Min', }), value: 'overall_min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallAvgLabel', + label: i18n.translate('visTypeTimeseries.table.overallAvgLabel', { defaultMessage: 'Overall Avg', }), value: 'overall_avg', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.cumulativeSumLabel', + label: i18n.translate('visTypeTimeseries.table.cumulativeSumLabel', { defaultMessage: 'Cumulative Sum', }), value: 'cumulative_sum', @@ -170,11 +162,8 @@ class TableSeriesConfigUI extends Component { > this.props.onChange({ filter })} indexPatterns={[this.props.indexPatternForQuery]} @@ -259,11 +248,9 @@ class TableSeriesConfigUI extends Component { } } -TableSeriesConfigUI.propTypes = { +TableSeriesConfig.propTypes = { fields: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; - -export const TableSeriesConfig = injectI18n(TableSeriesConfigUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js index a56afd1f817b3..acd2f4cc17d4a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js @@ -186,7 +186,7 @@ TableSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const TableSeries = injectI18n(TableSeriesUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 3df12dafd5a66..22bf2fa4ca708 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -542,7 +542,7 @@ export const TimeseriesConfig = injectI18n(function (props) { {...props} prefix="series_" disabled={!model.override_index_pattern} - allowLevelofDetail={true} + allowLevelOfDetail={true} /> @@ -555,6 +555,6 @@ TimeseriesConfig.propTypes = { model: PropTypes.object, panel: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js index 76df07ce7c8c4..bb10ac57c5ae9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js @@ -209,7 +209,7 @@ TimeseriesSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js index bfe446a8226e8..61bb7e2473dd9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js @@ -200,5 +200,5 @@ TopNSeries.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts new file mode 100644 index 0000000000000..534f686ca13fc --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { PanelSchema } from '../../../common/types'; + +export const PanelModelContext = React.createContext(null); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts index 088930f90a765..af3ddd643cac8 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts @@ -9,12 +9,14 @@ import { i18n } from '@kbn/i18n'; import { getCoreStart, getDataStart } from '../../services'; import { ROUTES } from '../../../common/constants'; -import { SanitizedFieldType } from '../../../common/types'; +import { SanitizedFieldType, IndexPatternValue } from '../../../common/types'; +import { getIndexPatternKey } from '../../../common/index_patterns_utils'; +import { toSanitizedFieldType } from '../../../common/fields_utils'; export type VisFields = Record; export async function fetchFields( - indexes: string[] = [], + indexes: IndexPatternValue[] = [], signal?: AbortSignal ): Promise { const patterns = Array.isArray(indexes) ? indexes : [indexes]; @@ -25,26 +27,33 @@ export async function fetchFields( const defaultIndexPattern = await dataStart.indexPatterns.getDefault(); const indexFields = await Promise.all( patterns.map(async (pattern) => { - return coreStart.http.get(ROUTES.FIELDS, { - query: { - index: pattern, - }, - signal, - }); + if (typeof pattern !== 'string' && pattern?.id) { + return toSanitizedFieldType( + (await dataStart.indexPatterns.get(pattern.id)).getNonScriptedFields() + ); + } else { + return coreStart.http.get(ROUTES.FIELDS, { + query: { + index: `${pattern ?? ''}`, + }, + signal, + }); + } }) ); const fields: VisFields = patterns.reduce( (cumulatedFields, currentPattern, index) => ({ ...cumulatedFields, - [currentPattern]: indexFields[index], + [getIndexPatternKey(currentPattern)]: indexFields[index], }), {} ); - if (defaultIndexPattern?.title && patterns.includes(defaultIndexPattern.title)) { - fields[''] = fields[defaultIndexPattern.title]; + if (defaultIndexPattern) { + fields[''] = toSanitizedFieldType(await defaultIndexPattern.getNonScriptedFields()); } + return fields; } catch (error) { if (error.name !== 'AbortError') { diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 9e996fcc74833..5d5e082b2b7bb 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { TSVB_EDITOR_NAME } from './application'; import { PANEL_TYPES } from '../common/panel_types'; +import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; import { toExpressionAst } from './to_ast'; import { VIS_EVENT_TO_TRIGGER, VisGroups, VisParams } from '../../visualizations/public'; import { getDataStart } from './services'; @@ -53,6 +54,7 @@ export const metricsVisDefinition = { ], time_field: '', index_pattern: '', + use_kibana_indexes: true, interval: '', axis_position: 'left', axis_formatter: 'number', @@ -77,7 +79,20 @@ export const metricsVisDefinition = { inspectorAdapters: {}, getUsedIndexPattern: async (params: VisParams) => { const { indexPatterns } = getDataStart(); + const indexPatternValue = params.index_pattern; - return params.index_pattern ? await indexPatterns.find(params.index_pattern) : []; + if (indexPatternValue) { + if (isStringTypeIndexPattern(indexPatternValue)) { + return await indexPatterns.find(indexPatternValue); + } + + if (indexPatternValue.id) { + return [await indexPatterns.get(indexPatternValue.id)]; + } + } + + const defaultIndex = await indexPatterns.getDefault(); + + return defaultIndex ? [defaultIndex] : []; }, }; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index f1bc5a11550e9..b0e85f8e44fbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -10,6 +10,7 @@ import { uniqBy } from 'lodash'; import { Framework } from '../plugin'; import { VisTypeTimeseriesFieldsRequest, VisTypeTimeseriesRequestHandlerContext } from '../types'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getFields( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -17,26 +18,29 @@ export async function getFields( framework: Framework, indexPatternString: string ) { + const indexPatternsService = await framework.getIndexPatternsService(requestContext); + const cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + if (!indexPatternString) { - const indexPatternsService = await framework.getIndexPatternsService(requestContext); const defaultIndexPattern = await indexPatternsService.getDefault(); indexPatternString = defaultIndexPattern?.title ?? ''; } + const fetchedIndex = await cachedIndexPatternFetcher(indexPatternString); + const { searchStrategy, capabilities, } = (await framework.searchStrategyRegistry.getViableStrategy( requestContext, request, - indexPatternString + fetchedIndex ))!; const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - request, - indexPatternString, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index 0ad50a296b481..d91104fb299d7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -19,6 +19,7 @@ import type { import { getSeriesData } from './vis_data/get_series_data'; import { getTableData } from './vis_data/get_table_data'; import { getEsQueryConfig } from './vis_data/helpers/get_es_query_uisettings'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getVisData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -29,12 +30,14 @@ export async function getVisData( const esShardTimeout = await framework.getEsShardTimeout(); const indexPatternsService = await framework.getIndexPatternsService(requestContext); const esQueryConfig = await getEsQueryConfig(uiSettings); + const services: VisTypeTimeseriesRequestServices = { esQueryConfig, esShardTimeout, indexPatternsService, uiSettings, searchStrategyRegistry: framework.searchStrategyRegistry, + cachedIndexPatternFetcher: getCachedIndexPatternFetcher(indexPatternsService), }; const promises = request.body.panels.map((panel) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts new file mode 100644 index 0000000000000..aeaf3ca2cd327 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 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 { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { + getCachedIndexPatternFetcher, + CachedIndexPatternFetcher, +} from './cached_index_pattern_fetcher'; + +describe('CachedIndexPatternFetcher', () => { + let mockedIndices: IndexPattern[] | []; + let cachedIndexPatternFetcher: CachedIndexPatternFetcher; + + beforeEach(() => { + mockedIndices = []; + + const indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + }); + + test('should return default index on no input value', async () => { + const value = await cachedIndexPatternFetcher(''); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return default index if Kibana index not found', async () => { + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts new file mode 100644 index 0000000000000..68cbd93cdc614 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_patterns_utils'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; + +export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatternsService) => { + const cache = new Map(); + + return async (indexPatternValue: IndexPatternValue): Promise => { + const key = getIndexPatternKey(indexPatternValue); + + if (cache.has(key)) { + return cache.get(key); + } + + const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); + + cache.set(indexPatternValue, fetchedIndex); + + return fetchedIndex; + }; +}; + +export type CachedIndexPatternFetcher = ReturnType; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts similarity index 57% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index f95667612efa4..9003eb7fc2ced 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -6,21 +6,26 @@ * Side Public License, v 1. */ -import { - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../../types'; -import { AbstractSearchStrategy, DefaultSearchCapabilities } from '../../search_strategies'; +import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; +import type { AbstractSearchStrategy, DefaultSearchCapabilities } from '../index'; +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; export interface FieldsFetcherServices { - requestContext: VisTypeTimeseriesRequestHandlerContext; + indexPatternsService: IndexPatternsService; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; searchStrategy: AbstractSearchStrategy; capabilities: DefaultSearchCapabilities; } export const createFieldsFetcher = ( req: VisTypeTimeseriesVisDataRequest, - { capabilities, requestContext, searchStrategy }: FieldsFetcherServices + { + capabilities, + indexPatternsService, + searchStrategy, + cachedIndexPatternFetcher, + }: FieldsFetcherServices ) => { const fieldsCacheMap = new Map(); @@ -28,11 +33,11 @@ export const createFieldsFetcher = ( if (fieldsCacheMap.has(index)) { return fieldsCacheMap.get(index); } + const fetchedIndex = await cachedIndexPatternFetcher(index); const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - req, - index, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts deleted file mode 100644 index 512494de290fd..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { IndexPatternsService, IndexPattern } from '../../../../../data/server'; - -interface IndexPatternObjectDependencies { - indexPatternsService: IndexPatternsService; -} -export async function getIndexPatternObject( - indexPatternString: string, - { indexPatternsService }: IndexPatternObjectDependencies -) { - let indexPatternObject: IndexPattern | undefined | null; - - if (!indexPatternString) { - indexPatternObject = await indexPatternsService.getDefault(); - } else { - indexPatternObject = (await indexPatternsService.find(indexPatternString)).find( - (index) => index.title === indexPatternString - ); - } - - return { - indexPatternObject, - indexPatternString: indexPatternObject?.title || indexPatternString || '', - }; -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index f9a49bc322a29..a6e7c5b11ee64 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -10,29 +10,27 @@ import { get } from 'lodash'; import { SearchStrategyRegistry } from './search_strategy_registry'; import { AbstractSearchStrategy, DefaultSearchStrategy } from './strategies'; import { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; -import { Framework } from '../../plugin'; import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; const getPrivateField = (registry: SearchStrategyRegistry, field: string) => get(registry, field) as T; class MockSearchStrategy extends AbstractSearchStrategy { - checkForViability() { - return Promise.resolve({ + async checkForViability() { + return { isViable: true, capabilities: {}, - }); + }; } } describe('SearchStrategyRegister', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let registry: SearchStrategyRegistry; beforeAll(() => { registry = new SearchStrategyRegistry(); - registry.addStrategy(new DefaultSearchStrategy(framework)); + registry.addStrategy(new DefaultSearchStrategy()); }); test('should init strategies register', () => { @@ -47,12 +45,11 @@ describe('SearchStrategyRegister', () => { test('should return a DefaultSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof DefaultSearchStrategy).toBe(true); @@ -60,7 +57,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -69,14 +66,13 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof MockSearchStrategy).toBe(true); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts index 11ff4b0a8a51f..4a013fd89735d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts @@ -6,14 +6,10 @@ * Side Public License, v 1. */ -import { extractIndexPatterns } from '../../../common/extract_index_patterns'; -import { PanelSchema } from '../../../common/types'; -import { - VisTypeTimeseriesRequest, - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../types'; +import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; import { AbstractSearchStrategy } from './strategies'; +import { FetchedIndexPattern } from '../../../common/types'; + export class SearchStrategyRegistry { private strategies: AbstractSearchStrategy[] = []; @@ -27,13 +23,13 @@ export class SearchStrategyRegistry { async getViableStrategy( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ) { for (const searchStrategy of this.strategies) { const { isViable, capabilities } = await searchStrategy.checkForViability( requestContext, req, - indexPattern + fetchedIndexPattern ); if (isViable) { @@ -44,14 +40,4 @@ export class SearchStrategyRegistry { } } } - - async getViableStrategyForPanel( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesVisDataRequest, - panel: PanelSchema - ) { - const indexPattern = extractIndexPatterns(panel, panel.default_index_pattern).join(','); - - return this.getViableStrategy(requestContext, req, indexPattern); - } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index e7282eba58ec7..fb66e32447c22 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -6,48 +6,26 @@ * Side Public License, v 1. */ -const mockGetFieldsForWildcard = jest.fn(() => []); - -jest.mock('../../../../../data/server', () => ({ - indexPatterns: { - isNestedField: jest.fn(() => false), - }, - IndexPatternsFetcher: jest.fn().mockImplementation(() => ({ - getFieldsForWildcard: mockGetFieldsForWildcard, - })), -})); +import { IndexPatternsService } from '../../../../../data/common'; import { from } from 'rxjs'; -import { AbstractSearchStrategy, toSanitizedFieldType } from './abstract_search_strategy'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; import type { IFieldType } from '../../../../../data/common'; -import type { FieldSpec, RuntimeField } from '../../../../../data/common'; -import { - VisTypeTimeseriesRequest, +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { Framework } from '../../../plugin'; -import { indexPatterns } from '../../../../../data/server'; class FooSearchStrategy extends AbstractSearchStrategy {} describe('AbstractSearchStrategy', () => { let abstractSearchStrategy: AbstractSearchStrategy; let mockedFields: IFieldType[]; - let indexPattern: string; let requestContext: VisTypeTimeseriesRequestHandlerContext; - let framework: Framework; beforeEach(() => { mockedFields = []; - framework = ({ - getIndexPatternsService: jest.fn(() => - Promise.resolve({ - find: jest.fn(() => []), - getDefault: jest.fn(() => {}), - }) - ), - } as unknown) as Framework; requestContext = ({ core: { elasticsearch: { @@ -60,7 +38,7 @@ describe('AbstractSearchStrategy', () => { search: jest.fn().mockReturnValue(from(Promise.resolve({}))), }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - abstractSearchStrategy = new FooSearchStrategy(framework); + abstractSearchStrategy = new FooSearchStrategy(); }); test('should init an AbstractSearchStrategy instance', () => { @@ -71,17 +49,15 @@ describe('AbstractSearchStrategy', () => { test('should return fields for wildcard', async () => { const fields = await abstractSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesRequest, - indexPattern + { indexPatternString: '', indexPattern: undefined }, + ({ + getDefault: jest.fn(), + getFieldsForWildcard: jest.fn(() => Promise.resolve(mockedFields)), + } as unknown) as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher ); expect(fields).toEqual(mockedFields); - expect(mockGetFieldsForWildcard).toHaveBeenCalledWith({ - pattern: indexPattern, - metaFields: [], - fieldCapsOptions: { allow_no_indices: true }, - }); }); test('should return response', async () => { @@ -117,68 +93,4 @@ describe('AbstractSearchStrategy', () => { } ); }); - - describe('toSanitizedFieldType', () => { - const mockedField = { - lang: 'lang', - conflictDescriptions: {}, - aggregatable: true, - name: 'name', - type: 'type', - esTypes: ['long', 'geo'], - } as FieldSpec; - - test('should sanitize fields ', async () => { - const fields = [mockedField] as FieldSpec[]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` - Array [ - Object { - "label": "name", - "name": "name", - "type": "type", - }, - ] - `); - }); - - test('should filter runtime fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - runtimeField: {} as RuntimeField, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter non-aggregatable fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - aggregatable: false, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter nested fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - subType: { - nested: { - path: 'path', - }, - }, - }, - ]; - // @ts-expect-error - indexPatterns.isNestedField.mockReturnValue(true); - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 5bc008091627f..26c3a6c7c8bf7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -6,37 +6,17 @@ * Side Public License, v 1. */ -import { indexPatterns, IndexPatternsFetcher } from '../../../../../data/server'; +import { IndexPatternsService } from '../../../../../data/server'; +import { toSanitizedFieldType } from '../../../../common/fields_utils'; -import type { Framework } from '../../../plugin'; -import type { FieldSpec } from '../../../../../data/common'; -import type { SanitizedFieldType } from '../../../../common/types'; +import type { FetchedIndexPattern } from '../../../../common/types'; import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { getIndexPatternObject } from '../lib/get_index_pattern'; - -export const toSanitizedFieldType = (fields: FieldSpec[]) => { - return fields - .filter( - (field) => - // Make sure to only include mapped fields, e.g. no index pattern runtime fields - !field.runtimeField && field.aggregatable && !indexPatterns.isNestedField(field) - ) - .map( - (field) => - ({ - name: field.name, - label: field.customLabel ?? field.name, - type: field.type, - } as SanitizedFieldType) - ); -}; export abstract class AbstractSearchStrategy { - constructor(private framework: Framework) {} async search( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesVisDataRequest, @@ -66,35 +46,25 @@ export abstract class AbstractSearchStrategy { checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ): Promise<{ isViable: boolean; capabilities: any }> { throw new TypeError('Must override method'); } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown, options?: Partial<{ type: string; rollupIndex: string; }> ) { - const indexPatternsFetcher = new IndexPatternsFetcher( - requestContext.core.elasticsearch.client.asCurrentUser - ); - const indexPatternsService = await this.framework.getIndexPatternsService(requestContext); - const { indexPatternObject } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); - return toSanitizedFieldType( - indexPatternObject - ? indexPatternObject.getNonScriptedFields() - : await indexPatternsFetcher!.getFieldsForWildcard({ - pattern: indexPattern, - fieldCapsOptions: { allow_no_indices: true }, + fetchedIndexPattern.indexPattern + ? fetchedIndexPattern.indexPattern.getNonScriptedFields() + : await indexPatternsService.getFieldsForWildcard({ + pattern: fetchedIndexPattern.indexPatternString ?? '', metaFields: [], ...options, }) diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index b9824355374e1..d7a4e6ddedc89 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { Framework } from '../../../plugin'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, @@ -14,14 +13,13 @@ import { import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let defaultSearchStrategy: DefaultSearchStrategy; let req: VisTypeTimeseriesVisDataRequest; beforeEach(() => { req = {} as VisTypeTimeseriesVisDataRequest; - defaultSearchStrategy = new DefaultSearchStrategy(framework); + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index c925d8fcbb7c3..f95bf81b5c1d3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -8,25 +8,30 @@ import { AbstractSearchStrategy } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; -import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequest } from '../../../types'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestHandlerContext, + VisTypeTimeseriesRequest, +} from '../../../types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { - checkForViability( + async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest ) { - return Promise.resolve({ + return { isViable: true, capabilities: new DefaultSearchCapabilities(req), - }); + }; } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities); + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index 403013cfb9e10..c798f58b0b67b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -7,8 +7,10 @@ */ import { RollupSearchStrategy } from './rollup_search_strategy'; -import { Framework } from '../../../plugin'; -import { + +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; @@ -49,12 +51,11 @@ describe('Rollup Search Strategy', () => { }, }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - const framework = {} as Framework; const indexPattern = 'indexPattern'; test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(framework); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy).toBeDefined(); }); @@ -64,7 +65,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => Promise.resolve({ [rollupIndex]: { @@ -99,7 +100,7 @@ describe('Rollup Search Strategy', () => { const result = await rollupSearchStrategy.checkForViability( requestContext, {} as VisTypeTimeseriesVisDataRequest, - (null as unknown) as string + { indexPatternString: (null as unknown) as string, indexPattern: undefined } ); expect(result).toEqual({ @@ -113,7 +114,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy: RollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -140,7 +141,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { @@ -154,9 +155,9 @@ describe('Rollup Search Strategy', () => { test('should return fields for wildcard', async () => { const fields = await rollupSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesVisDataRequest, - indexPattern, + { indexPatternString: 'indexPattern', indexPattern: undefined }, + {} as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher, { fieldsCapabilities, rollupIndex, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 376d551624c8a..e6333ca420e0d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -6,19 +6,20 @@ * Side Public License, v 1. */ -import { getCapabilitiesForRollupIndices } from '../../../../../data/server'; -import { +import { getCapabilitiesForRollupIndices, IndexPatternsService } from '../../../../../data/server'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; + +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); -const isIndexPatternValid = (indexPattern: string) => - indexPattern && typeof indexPattern === 'string' && !isIndexPatternContainsWildcard(indexPattern); export class RollupSearchStrategy extends AbstractSearchStrategy { async search( @@ -33,24 +34,33 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { requestContext: VisTypeTimeseriesRequestHandlerContext, indexPattern: string ) { - return requestContext.core.elasticsearch.client.asCurrentUser.rollup - .getRollupIndexCaps({ + try { + const { + body, + } = await requestContext.core.elasticsearch.client.asCurrentUser.rollup.getRollupIndexCaps({ index: indexPattern, - }) - .then((data) => data.body) - .catch(() => Promise.resolve({})); + }); + + return body; + } catch (e) { + return {}; + } } async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + { indexPatternString, indexPattern }: FetchedIndexPattern ) { let isViable = false; let capabilities = null; - if (isIndexPatternValid(indexPattern)) { - const rollupData = await this.getRollupData(requestContext, indexPattern); + if ( + indexPatternString && + !isIndexPatternContainsWildcard(indexPatternString) && + (!indexPattern || indexPattern.type === 'rollup') + ) { + const rollupData = await this.getRollupData(requestContext, indexPatternString); const rollupIndices = getRollupIndices(rollupData); isViable = rollupIndices.length === 1; @@ -70,14 +80,14 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, + getCachedIndexPatternFetcher: CachedIndexPatternFetcher, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities, { + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities, { type: 'rollup', - rollupIndex: indexPattern, + rollupIndex: fetchedIndexPattern.indexPatternString, }); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts index c489a8d20b071..32086fbf4f5b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts @@ -8,7 +8,6 @@ import { AnnotationItemsSchema, PanelSchema } from 'src/plugins/vis_type_timeseries/common/types'; import { buildAnnotationRequest } from './build_request_body'; -import { getIndexPatternObject } from '../../search_strategies/lib/get_index_pattern'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequestServices, @@ -30,21 +29,20 @@ export async function getAnnotationRequestParams( esShardTimeout, esQueryConfig, capabilities, - indexPatternsService, uiSettings, + cachedIndexPatternFetcher, }: AnnotationServices ) { - const { - indexPatternObject, - indexPatternString, - } = await getIndexPatternObject(annotation.index_pattern!, { indexPatternsService }); + const { indexPattern, indexPatternString } = await cachedIndexPatternFetcher( + annotation.index_pattern + ); const request = await buildAnnotationRequest( req, panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index 9b371a8901e81..ebab984ff25aa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -10,8 +10,8 @@ import { AUTO_INTERVAL } from '../../../common/constants'; const DEFAULT_TIME_FIELD = '@timestamp'; -export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { - const getDefaultTimeField = () => indexPatternObject?.timeFieldName ?? DEFAULT_TIME_FIELD; +export function getIntervalAndTimefield(panel, series = {}, indexPattern) { + const getDefaultTimeField = () => indexPattern?.timeFieldName ?? DEFAULT_TIME_FIELD; const timeField = (series.override_index_pattern && series.series_time_field) || diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts index f521de632b1f8..13dc1207f51de 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts @@ -21,6 +21,7 @@ import type { VisTypeTimeseriesRequestServices, } from '../../types'; import type { PanelSchema } from '../../../common/types'; +import { PANEL_TYPES } from '../../../common/panel_types'; export async function getSeriesData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -28,10 +29,12 @@ export async function getSeriesData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const strategy = await services.searchStrategyRegistry.getViableStrategyForPanel( + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); + + const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panel + panelIndex ); if (!strategy) { @@ -50,14 +53,15 @@ export async function getSeriesData( try { const bodiesPromises = getActiveSeries(panel).map((series) => - getSeriesRequestParams(req, panel, series, capabilities, services) + getSeriesRequestParams(req, panel, panelIndex, series, capabilities, services) ); const searches = await Promise.all(bodiesPromises); const data = await searchStrategy.search(requestContext, req, searches); const handleResponseBodyFn = handleResponseBody(panel, req, { - requestContext, + indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, searchStrategy, capabilities, }); @@ -70,7 +74,7 @@ export async function getSeriesData( let annotations = null; - if (panel.annotations && panel.annotations.length) { + if (panel.type === PANEL_TYPES.TIMESERIES && panel.annotations && panel.annotations.length) { annotations = await getAnnotations({ req, panel, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts index a35a3246b0dd3..0cc1188086b7b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts @@ -16,8 +16,8 @@ import { buildRequestBody } from './table/build_request_body'; import { handleErrorResponse } from './handle_error_response'; // @ts-expect-error import { processBucket } from './table/process_bucket'; -import { getIndexPatternObject } from '../search_strategies/lib/get_index_pattern'; -import { createFieldsFetcher } from './helpers/fields_fetcher'; + +import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher'; import { extractFieldLabel } from '../../../common/calculate_label'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -32,12 +32,12 @@ export async function getTableData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const panelIndexPattern = panel.index_pattern; + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panelIndexPattern + panelIndex ); if (!strategy) { @@ -49,15 +49,17 @@ export async function getTableData( } const { searchStrategy, capabilities } = strategy; - const { indexPatternObject } = await getIndexPatternObject(panelIndexPattern, { + + const extractFields = createFieldsFetcher(req, { indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, + searchStrategy, + capabilities, }); - const extractFields = createFieldsFetcher(req, { requestContext, searchStrategy, capabilities }); - const calculatePivotLabel = async () => { - if (panel.pivot_id && indexPatternObject?.title) { - const fields = await extractFields(indexPatternObject.title); + if (panel.pivot_id && panelIndex.indexPattern?.title) { + const fields = await extractFields(panelIndex.indexPattern.title); return extractFieldLabel(fields, panel.pivot_id); } @@ -75,7 +77,7 @@ export async function getTableData( req, panel, services.esQueryConfig, - indexPatternObject, + panelIndex.indexPattern, capabilities, services.uiSettings ); @@ -83,7 +85,7 @@ export async function getTableData( const [resp] = await searchStrategy.search(requestContext, req, [ { body, - index: panelIndexPattern, + index: panelIndex.indexPatternString, }, ]); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 0d100f6310b99..48b33c1e787e9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -18,7 +18,7 @@ export function dateHistogram( panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index 9ff0325b60e82..dab9a24d06c0f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -19,7 +19,7 @@ export function dateHistogram( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { @@ -27,11 +27,7 @@ export function dateHistogram( const maxBarsUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval, maxBars } = getIntervalAndTimefield( - panel, - series, - indexPatternObject - ); + const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize, intervalString } = getBucketSize( req, interval, @@ -68,7 +64,7 @@ export function dateHistogram( overwrite(doc, `aggs.${series.id}.meta`, { timeField, intervalString, - index: indexPatternObject?.title, + index: indexPattern?.title, bucketSize, seriesId: series.id, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index d653f6acf6f3e..945c57b2341f3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -16,7 +16,7 @@ describe('dateHistogram(req, panel, series)', () => { let req; let capabilities; let config; - let indexPatternObject; + let indexPattern; let uiSettings; beforeEach(() => { @@ -39,7 +39,7 @@ describe('dateHistogram(req, panel, series)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - indexPatternObject = {}; + indexPattern = {}; capabilities = new DefaultSearchCapabilities(req); uiSettings = { get: async (key) => (key === UI_SETTINGS.HISTOGRAM_MAX_BARS ? 100 : 50), @@ -49,15 +49,9 @@ describe('dateHistogram(req, panel, series)', () => { test('calls next when finished', async () => { const next = jest.fn(); - await dateHistogram( - req, - panel, - series, - config, - indexPatternObject, - capabilities, - uiSettings - )(next)({}); + await dateHistogram(req, panel, series, config, indexPattern, capabilities, uiSettings)(next)( + {} + ); expect(next.mock.calls.length).toEqual(1); }); @@ -69,7 +63,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -110,7 +104,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -154,7 +148,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -198,7 +192,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -216,7 +210,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js index 31ae988718a27..4639af9db83b8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -12,19 +12,19 @@ import { esQuery } from '../../../../../../data/server'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, series, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { if (series.metrics.some(filter)) { series.metrics.filter(filter).forEach((metric) => { overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js index 9e0dd4f76c13f..345488ec01d5e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js @@ -13,7 +13,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => let series; let req; let esQueryConfig; - let indexPatternObject; + let indexPattern; beforeEach(() => { panel = { time_field: 'timestamp', @@ -47,18 +47,18 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => queryStringOptions: { analyze_wildcard: true }, ignoreFilterIfFieldNotInIndex: false, }; - indexPatternObject = {}; + indexPattern = {}; }); test('calls next when finished', () => { const next = jest.fn(); - ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns filter ratio aggs', () => { const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -135,7 +135,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => test('returns empty object when field is not set', () => { delete series.metrics[0].field; const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 649b3cee6ea3e..86b691f6496c9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -17,14 +17,14 @@ export function metricBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 1d67df7c92eb6..ce61374c0b124 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -56,14 +56,14 @@ export function positiveRate( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); if (series.metrics.some(filter)) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js index cb12aa3513b91..d0e92c9157cb5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js @@ -10,16 +10,16 @@ import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, series, esQueryConfig, indexPatternObject) { +export function query(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, series, indexPattern); const { from, to } = offsetTime(req, series.offset_time); doc.size = 0; const ignoreGlobalFilter = panel.ignore_global_filter || series.ignore_global_filter; const queries = !ignoreGlobalFilter ? req.body.query : []; const filters = !ignoreGlobalFilter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -34,13 +34,13 @@ export function query(req, panel, series, esQueryConfig, indexPatternObject) { if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } if (series.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [series.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index 315ccdfc13a47..401344d48f865 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -17,13 +17,13 @@ export function siblingBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 0ae6d113e28e4..5518065643172 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -15,20 +15,13 @@ import { calculateAggRoot } from './calculate_agg_root'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function dateHistogram(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPattern); const meta = { timeField, - index: indexPatternObject?.title, + index: indexPattern?.title, }; const getDateHistogramForLastBucketMode = () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js index 7b3ac16cd6561..abb5971908771 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js @@ -13,7 +13,7 @@ import { calculateAggRoot } from './calculate_agg_root'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -22,12 +22,12 @@ export function ratios(req, panel, esQueryConfig, indexPatternObject) { overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 53149a31603ef..5ce508bd9b279 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index 8c7a0f5e2367f..176721e7b563a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -12,17 +12,10 @@ import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function positiveRate( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function positiveRate(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js index a0118c5037d34..76df07b76e80e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js @@ -10,16 +10,16 @@ import { getTimerange } from '../../helpers/get_timerange'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, esQueryConfig, indexPatternObject) { +export function query(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, {}, indexPattern); const { from, to } = getTimerange(req); doc.size = 0; const queries = !panel.ignore_global_filter ? req.body.query : []; const filters = !panel.ignore_global_filter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -33,7 +33,7 @@ export function query(req, panel, esQueryConfig, indexPatternObject) { doc.query.bool.must.push(timerange); if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index d205f0679a908..5539f16df41e0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 968fe01565b04..d97af8ac748f4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -79,13 +79,13 @@ describe('buildRequestBody(req)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - const indexPatternObject = {}; + const indexPattern = {}; const doc = await buildRequestBody( { body }, panel, series, config, - indexPatternObject, + indexPattern, capabilities, { get: async () => 50, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts index ae846b5b4b817..1f2735da8fb06 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts @@ -6,43 +6,46 @@ * Side Public License, v 1. */ -import { PanelSchema, SeriesItemsSchema } from '../../../../common/types'; import { buildRequestBody } from './build_request_body'; -import { getIndexPatternObject } from '../../../lib/search_strategies/lib/get_index_pattern'; -import { VisTypeTimeseriesRequestServices, VisTypeTimeseriesVisDataRequest } from '../../../types'; -import { DefaultSearchCapabilities } from '../../search_strategies'; + +import type { FetchedIndexPattern, PanelSchema, SeriesItemsSchema } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestServices, + VisTypeTimeseriesVisDataRequest, +} from '../../../types'; +import type { DefaultSearchCapabilities } from '../../search_strategies'; export async function getSeriesRequestParams( req: VisTypeTimeseriesVisDataRequest, panel: PanelSchema, + panelIndex: FetchedIndexPattern, series: SeriesItemsSchema, capabilities: DefaultSearchCapabilities, { esQueryConfig, esShardTimeout, uiSettings, - indexPatternsService, + cachedIndexPatternFetcher, }: VisTypeTimeseriesRequestServices ) { - const indexPattern = - (series.override_index_pattern && series.series_index_pattern) || panel.index_pattern; + let seriesIndex = panelIndex; - const { indexPatternObject, indexPatternString } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); + if (series.override_index_pattern) { + seriesIndex = await cachedIndexPatternFetcher(series.series_index_pattern ?? ''); + } const request = await buildRequestBody( req, panel, series, esQueryConfig, - indexPatternObject, + seriesIndex.indexPattern, capabilities, uiSettings ); return { - index: indexPatternString, + index: seriesIndex.indexPatternString, body: { ...request, timeout: esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts index 22e0372c23526..49f1ec0f93de5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts @@ -12,7 +12,10 @@ import { PanelSchema } from '../../../../common/types'; import { buildProcessorFunction } from '../build_processor_function'; // @ts-expect-error import { processors } from '../response_processors/series'; -import { createFieldsFetcher, FieldsFetcherServices } from './../helpers/fields_fetcher'; +import { + createFieldsFetcher, + FieldsFetcherServices, +} from '../../search_strategies/lib/fields_fetcher'; import { VisTypeTimeseriesVisDataRequest } from '../../../types'; export function handleResponseBody( diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 71b76dddbca6a..8bc752e944709 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -37,6 +37,8 @@ import { } from './lib/search_strategies'; import { TimeseriesVisData, VisPayload } from '../common/types'; +import { registerTimeseriesUsageCollector } from './usage_collector'; + export interface LegacySetup { server: Server; } @@ -111,12 +113,16 @@ export class VisTypeTimeseriesPlugin implements Plugin { }, }; - searchStrategyRegistry.addStrategy(new DefaultSearchStrategy(framework)); - searchStrategyRegistry.addStrategy(new RollupSearchStrategy(framework)); + searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); + searchStrategyRegistry.addStrategy(new RollupSearchStrategy()); visDataRoutes(router, framework); fieldsRoutes(router, framework); + if (plugins.usageCollection) { + registerTimeseriesUsageCollector(plugins.usageCollection, globalConfig$); + } + return { getVisData: async ( requestContext: VisTypeTimeseriesRequestHandlerContext, diff --git a/src/plugins/vis_type_timeseries/server/types.ts b/src/plugins/vis_type_timeseries/server/types.ts index da32669b3855d..7b42cf61d52b3 100644 --- a/src/plugins/vis_type_timeseries/server/types.ts +++ b/src/plugins/vis_type_timeseries/server/types.ts @@ -6,14 +6,19 @@ * Side Public License, v 1. */ +import { Observable } from 'rxjs'; +import { SharedGlobalConfig } from 'kibana/server'; import type { IRouter, IUiSettingsClient, KibanaRequest } from 'src/core/server'; import type { DataRequestHandlerContext, EsQueryConfig, IndexPatternsService, } from '../../data/server'; -import { VisPayload } from '../common/types'; -import { SearchStrategyRegistry } from './lib/search_strategies'; +import type { VisPayload } from '../common/types'; +import type { SearchStrategyRegistry } from './lib/search_strategies'; +import type { CachedIndexPatternFetcher } from './lib/search_strategies/lib/cached_index_pattern_fetcher'; + +export type ConfigObservable = Observable; export type VisTypeTimeseriesRequestHandlerContext = DataRequestHandlerContext; export type VisTypeTimeseriesRouter = IRouter; @@ -29,4 +34,5 @@ export interface VisTypeTimeseriesRequestServices { uiSettings: IUiSettingsClient; indexPatternsService: IndexPatternsService; searchStrategyRegistry: SearchStrategyRegistry; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; } diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.ts new file mode 100644 index 0000000000000..bb52d215c67e8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.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 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 const mockStats = { somestat: 1 }; +export const mockGetStats = jest.fn().mockResolvedValue(mockStats); + +jest.doMock('./get_usage_collector', () => ({ + getStats: mockGetStats, +})); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts new file mode 100644 index 0000000000000..8ecc02072905f --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 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 { getStats } from './get_usage_collector'; +import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { TIME_RANGE_DATA_MODES } from '../../common/timerange_data_modes'; + +const mockedSavedObjects = [ + { + _id: 'visualization:timeseries-123', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 1', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, + }, + }), + }, + }, + }, + { + _id: 'visualization:timeseries-321', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 2', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.LAST_VALUE, + }, + }), + }, + }, + }, + { + _id: 'visualization:timeseries-456', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 3', + params: { + time_range_mode: undefined, + }, + }), + }, + }, + }, +]; + +const mockedSavedObjectsByValue = [ + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.LAST_VALUE, + }, + }, + }, + }), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, + }, + }, + }, + }), + }, + }, +]; + +const getMockCollectorFetchContext = (hits?: unknown[], savedObjectsByValue: unknown[] = []) => { + const fetchParamsMock = createCollectorFetchContextMock(); + + fetchParamsMock.esClient.search = jest.fn().mockResolvedValue({ body: { hits: { hits } } }); + fetchParamsMock.soClient.find = jest.fn().mockResolvedValue({ + saved_objects: savedObjectsByValue, + }); + return fetchParamsMock; +}; + +describe('Timeseries visualization usage collector', () => { + const mockIndex = 'mock_index'; + + test('Returns undefined when no results found (undefined)', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext([], []); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toBeUndefined(); + }); + + test('Returns undefined when no timeseries saved objects found', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext( + [ + { + _id: 'visualization:myvis-123', + _source: { + type: 'visualization', + visualization: { visState: '{"type": "area"}' }, + }, + }, + ], + [ + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'area', + }, + }, + }), + }, + }, + ] + ); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toBeUndefined(); + }); + + test('Summarizes visualizations response data', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext( + mockedSavedObjects, + mockedSavedObjectsByValue + ); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toMatchObject({ + timeseries_use_last_value_mode_total: 3, + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts new file mode 100644 index 0000000000000..c1a8715f72227 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts @@ -0,0 +1,86 @@ +/* + * Copyright 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 { ElasticsearchClient } from 'src/core/server'; +import { SavedObjectsClientContract, ISavedObjectsRepository } from 'kibana/server'; +import { TIME_RANGE_DATA_MODES } from '../../common/timerange_data_modes'; +import { findByValueEmbeddables } from '../../../dashboard/server'; + +export interface TimeseriesUsage { + timeseries_use_last_value_mode_total: number; +} + +interface VisState { + type?: string; + params?: any; +} + +export const getStats = async ( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract | ISavedObjectsRepository, + index: string +): Promise => { + const timeseriesUsage = { + timeseries_use_last_value_mode_total: 0, + }; + + const searchParams = { + size: 10000, + index, + ignoreUnavailable: true, + filterPath: ['hits.hits._id', 'hits.hits._source.visualization'], + body: { + query: { + bool: { + filter: { term: { type: 'visualization' } }, + }, + }, + }, + }; + + const { body: esResponse } = await esClient.search<{ + visualization: { visState: string }; + updated_at: string; + }>(searchParams); + + function telemetryUseLastValueMode(visState: VisState) { + if ( + visState.type === 'metrics' && + visState.params.type !== 'timeseries' && + (!visState.params.time_range_mode || + visState.params.time_range_mode === TIME_RANGE_DATA_MODES.LAST_VALUE) + ) { + timeseriesUsage.timeseries_use_last_value_mode_total++; + } + } + + if (esResponse?.hits?.hits?.length) { + for (const hit of esResponse.hits.hits) { + if (hit._source && 'visualization' in hit._source) { + const { visualization } = hit._source!; + + let visState: VisState = {}; + try { + visState = JSON.parse(visualization?.visState ?? '{}'); + } catch (e) { + // invalid visState + } + + telemetryUseLastValueMode(visState); + } + } + } + + const byValueVisualizations = await findByValueEmbeddables(soClient, 'visualization'); + + for (const item of byValueVisualizations) { + telemetryUseLastValueMode(item.savedVis as VisState); + } + + return timeseriesUsage.timeseries_use_last_value_mode_total ? timeseriesUsage : undefined; +}; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/index.ts b/src/plugins/vis_type_timeseries/server/usage_collector/index.ts new file mode 100644 index 0000000000000..7f72662e154ea --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { registerTimeseriesUsageCollector } from './register_timeseries_collector'; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts new file mode 100644 index 0000000000000..2612a3882af2d --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts @@ -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 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 { of } from 'rxjs'; +import { mockStats, mockGetStats } from './get_usage_collector.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { registerTimeseriesUsageCollector } from './register_timeseries_collector'; +import { ConfigObservable } from '../types'; + +describe('registerTimeseriesUsageCollector', () => { + const mockIndex = 'mock_index'; + const mockConfig = of({ kibana: { index: mockIndex } }) as ConfigObservable; + + it('makes a usage collector and registers it`', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + expect(mockCollectorSet.makeUsageCollector).toBeCalledTimes(1); + expect(mockCollectorSet.registerCollector).toBeCalledTimes(1); + }); + + it('makeUsageCollector configs fit the shape', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + expect(mockCollectorSet.makeUsageCollector).toHaveBeenCalledWith({ + type: 'vis_type_timeseries', + isReady: expect.any(Function), + fetch: expect.any(Function), + schema: expect.any(Object), + }); + const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + expect(usageCollectorConfig.isReady()).toBe(true); + }); + + it('makeUsageCollector config.isReady returns true', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + expect(usageCollectorConfig.isReady()).toBe(true); + }); + + it('makeUsageCollector config.fetch calls getStats', async () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value; + const mockedCollectorFetchContext = createCollectorFetchContextMock(); + const fetchResult = await usageCollector.fetch(mockedCollectorFetchContext); + expect(mockGetStats).toBeCalledTimes(1); + expect(mockGetStats).toBeCalledWith( + mockedCollectorFetchContext.esClient, + mockedCollectorFetchContext.soClient, + mockIndex + ); + expect(fetchResult).toBe(mockStats); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts new file mode 100644 index 0000000000000..5edeb6654020e --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { first } from 'rxjs/operators'; +import { getStats, TimeseriesUsage } from './get_usage_collector'; +import { ConfigObservable } from '../types'; + +export function registerTimeseriesUsageCollector( + collectorSet: UsageCollectionSetup, + config: ConfigObservable +) { + const collector = collectorSet.makeUsageCollector({ + type: 'vis_type_timeseries', + isReady: () => true, + schema: { + timeseries_use_last_value_mode_total: { + type: 'long', + _meta: { description: 'Number of TSVB visualizations using "last value" as a time range' }, + }, + }, + fetch: async ({ esClient, soClient }) => { + const { index } = (await config.pipe(first()).toPromise()).kibana; + + return await getStats(esClient, soClient, index); + }, + }); + + collectorSet.registerCollector(collector); +} diff --git a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx index efb41c470024b..f5b0f614458fd 100644 --- a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx @@ -11,6 +11,8 @@ import { EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } fr import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { getDocLinks } from '../services'; + function VegaHelpMenu() { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const onButtonClick = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); @@ -30,7 +32,7 @@ function VegaHelpMenu() { const items = [ diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 0204c2c90b71b..f935362d21604 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -19,6 +19,7 @@ import { setUISettings, setInjectedMetadata, setMapServiceSettings, + setDocLinks, } from './services'; import { createVegaFn } from './vega_fn'; @@ -96,5 +97,6 @@ export class VegaPlugin implements Plugin { setNotifications(core.notifications); setData(data); setInjectedMetadata(core.injectedMetadata); + setDocLinks(core.docLinks); } } diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index c47378282932b..f67fe4794e783 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/public'; +import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from 'src/core/public'; import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; @@ -35,3 +35,5 @@ export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ }>('InjectedVars'); export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; + +export const [getDocLinks, setDocLinks] = createGetterSetter('docLinks'); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 349e024f31c31..c2b9fcd77757a 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -12,6 +12,8 @@ import { first } from 'rxjs/operators'; import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { SavedObjectAttributes } from '../../../../core/public'; import { extractSearchSourceReferences } from '../../../data/public'; +import { SavedObjectReference } from '../../../../core/public'; + import { EmbeddableFactoryDefinition, EmbeddableOutput, @@ -38,6 +40,12 @@ import { } from '../services'; import { showNewVisModal } from '../wizard'; import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; +import { + extractControlsReferences, + extractTimeSeriesReferences, + injectTimeSeriesReferences, + injectControlsReferences, +} from '../saved_visualizations/saved_visualization_references'; import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object'; import { StartServicesGetter } from '../../../kibana_utils/public'; import { VisualizationsStartDeps } from '../plugin'; @@ -239,6 +247,19 @@ export class VisualizeEmbeddableFactory ); } + public inject(_state: EmbeddableStateWithType, references: SavedObjectReference[]) { + const state = (_state as unknown) as VisualizeInput; + + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); + } + + return _state; + } + public extract(_state: EmbeddableStateWithType) { const state = (_state as unknown) as VisualizeInput; const references = []; @@ -259,19 +280,11 @@ export class VisualizeEmbeddableFactory }); } - if (state.savedVis?.params.controls) { - const controls = state.savedVis.params.controls; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - references.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - }); + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + extractControlsReferences(type, params, references, `control_${state.id}`); + extractTimeSeriesReferences(type, params, references, `metrics_${state.id}`); } return { state: _state, references }; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts new file mode 100644 index 0000000000000..d116fd2e2e9a7 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +const isControlsVis = (visType: string) => visType === 'input_control_vis'; + +export const extractControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'control' +) => { + if (isControlsVis(visType)) { + (visParams?.controls ?? []).forEach((control: Record, i: number) => { + if (!control.indexPattern) { + return; + } + control.indexPatternRefName = `${prefix}_${i}_index_pattern`; + references.push({ + name: control.indexPatternRefName, + type: 'index-pattern', + id: control.indexPattern, + }); + delete control.indexPattern; + }); + } +}; + +export const injectControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isControlsVis(visType)) { + (visParams.controls ?? []).forEach((control: Record) => { + if (!control.indexPatternRefName) { + return; + } + const reference = references.find((ref) => ref.name === control.indexPatternRefName); + if (!reference) { + throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); + } + control.indexPattern = reference.id; + delete control.indexPatternRefName; + }); + } +}; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts new file mode 100644 index 0000000000000..0acda1c0a0f80 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { extractControlsReferences, injectControlsReferences } from './controls_references'; +export { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; + +export { extractReferences, injectReferences } from './saved_visualization_references'; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts similarity index 69% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts index f81054febcc44..867febd2544b0 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts @@ -7,8 +7,8 @@ */ import { extractReferences, injectReferences } from './saved_visualization_references'; -import { VisSavedObject } from '../types'; -import { SavedVisState } from '../../common'; +import { VisSavedObject } from '../../types'; +import { SavedVisState } from '../../../common'; describe('extractReferences', () => { test('extracts nothing if savedSearchId is empty', () => { @@ -21,13 +21,13 @@ describe('extractReferences', () => { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - }, - "references": Array [], -} -`); + Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], + } + `); }); test('extracts references from savedSearchId', () => { @@ -41,20 +41,20 @@ Object { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "savedSearchRefName": "search_0", - }, - "references": Array [ - Object { - "id": "123", - "name": "search_0", - "type": "search", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "savedSearchRefName": "search_0", + }, + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + } + `); }); test('extracts references from controls', () => { @@ -63,6 +63,7 @@ Object { attributes: { foo: true, visState: JSON.stringify({ + type: 'input_control_vis', params: { controls: [ { @@ -81,20 +82,20 @@ Object { const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "visState": "{\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", - }, - "references": Array [ - Object { - "id": "pattern*", - "name": "control_0_index_pattern", - "type": "index-pattern", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "visState": "{\\"type\\":\\"input_control_vis\\",\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", + }, + "references": Array [ + Object { + "id": "pattern*", + "name": "control_0_index_pattern", + "type": "index-pattern", + }, + ], + } + `); }); }); @@ -106,11 +107,11 @@ describe('injectReferences', () => { } as VisSavedObject; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "title": "test", -} -`); + Object { + "id": "1", + "title": "test", + } + `); }); test('injects references into context', () => { @@ -119,6 +120,7 @@ Object { title: 'test', savedSearchRefName: 'search_0', visState: ({ + type: 'input_control_vis', params: { controls: [ { @@ -146,25 +148,26 @@ Object { ]; injectReferences(context, references); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "savedSearchId": "123", - "title": "test", - "visState": Object { - "params": Object { - "controls": Array [ - Object { - "foo": true, - "indexPattern": "pattern*", - }, - Object { - "foo": false, + Object { + "id": "1", + "savedSearchId": "123", + "title": "test", + "visState": Object { + "params": Object { + "controls": Array [ + Object { + "foo": true, + "indexPattern": "pattern*", + }, + Object { + "foo": false, + }, + ], + }, + "type": "input_control_vis", }, - ], - }, - }, -} -`); + } + `); }); test(`fails when it can't find the saved search reference in the array`, () => { @@ -183,6 +186,7 @@ Object { id: '1', title: 'test', visState: ({ + type: 'input_control_vis', params: { controls: [ { diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts similarity index 67% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts index 27b5a4542036b..6a4f9812db971 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts @@ -10,13 +10,16 @@ import { SavedObjectAttribute, SavedObjectAttributes, SavedObjectReference, -} from '../../../../core/public'; -import { VisSavedObject } from '../types'; +} from '../../../../../core/public'; +import { SavedVisState, VisSavedObject } from '../../types'; import { extractSearchSourceReferences, injectSearchSourceReferences, SearchSourceFields, -} from '../../../data/public'; +} from '../../../../data/public'; + +import { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; +import { extractControlsReferences, injectControlsReferences } from './controls_references'; export function extractReferences({ attributes, @@ -49,20 +52,13 @@ export function extractReferences({ // Extract index patterns from controls if (updatedAttributes.visState) { - const visState = JSON.parse(String(updatedAttributes.visState)); - const controls = (visState.params && visState.params.controls) || []; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - updatedReferences.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - delete control.indexPattern; - }); + const visState = JSON.parse(String(updatedAttributes.visState)) as SavedVisState; + + if (visState.type && visState.params) { + extractControlsReferences(visState.type, visState.params, updatedReferences); + extractTimeSeriesReferences(visState.type, visState.params, updatedReferences); + } + updatedAttributes.visState = JSON.stringify(visState); } @@ -89,18 +85,11 @@ export function injectReferences(savedObject: VisSavedObject, references: SavedO savedObject.savedSearchId = savedSearchReference.id; delete savedObject.savedSearchRefName; } - if (savedObject.visState) { - const controls = (savedObject.visState.params && savedObject.visState.params.controls) || []; - controls.forEach((control: Record) => { - if (!control.indexPatternRefName) { - return; - } - const reference = references.find((ref) => ref.name === control.indexPatternRefName); - if (!reference) { - throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); - } - control.indexPattern = reference.id; - delete control.indexPatternRefName; - }); + + const { type, params } = savedObject.visState ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); } } diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts new file mode 100644 index 0000000000000..57706ee824e8d --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +/** @internal **/ +const REF_NAME_POSTFIX = '_ref_name'; + +/** @internal **/ +const INDEX_PATTERN_REF_TYPE = 'index_pattern'; + +/** @internal **/ +type Action = (object: Record, key: string) => void; + +const isMetricsVis = (visType: string) => visType === 'metrics'; + +const doForExtractedIndices = (action: Action, visParams: VisParams) => { + action(visParams, 'index_pattern'); + + visParams.series.forEach((series: any) => { + if (series.override_index_pattern) { + action(series, 'series_index_pattern'); + } + }); + + if (visParams.annotations) { + visParams.annotations.forEach((annotation: any) => { + action(annotation, 'index_pattern'); + }); + } +}; + +export const extractTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'metrics' +) => { + let i = 0; + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + if (object[key] && object[key].id) { + const name = `${prefix}_${i++}_index_pattern`; + + object[key + REF_NAME_POSTFIX] = name; + references.push({ + name, + type: INDEX_PATTERN_REF_TYPE, + id: object[key].id, + }); + delete object[key]; + } + }, visParams); + } +}; + +export const injectTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + const refKey = key + REF_NAME_POSTFIX; + + if (object[refKey]) { + const refValue = references.find((ref) => ref.name === object[refKey]); + + if (refValue) { + object[key] = { id: refValue.id }; + } + + delete object[refKey]; + } + }, visParams); + } +}; diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts index afb59266d0dbf..ced33318413c5 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts @@ -790,6 +790,35 @@ const removeTSVBSearchSource: SavedObjectMigrationFn = (doc) => { return doc; }; +const addSupportOfDualIndexSelectionModeInTSVB: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + if (visState && visState.type === 'metrics') { + const { params } = visState; + + if (typeof params?.index_pattern === 'string') { + params.use_kibana_indexes = false; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(visState), + }, + }; + } + } + return doc; +}; + // [Data table visualization] Enable toolbar by default const enableDataTableVisToolbar: SavedObjectMigrationFn = (doc) => { let visState; @@ -929,4 +958,5 @@ export const visualizationSavedObjectTypeMigrations = { '7.10.0': flow(migrateFilterRatioQuery, removeTSVBSearchSource), '7.11.0': flow(enableDataTableVisToolbar), '7.12.0': flow(migrateVislibAreaLineBarTypes, migrateSchema), + '7.13.0': flow(addSupportOfDualIndexSelectionModeInTSVB), }; diff --git a/src/setup_node_env/index.js b/src/setup_node_env/index.js index 08664344db393..9ce60766997cc 100644 --- a/src/setup_node_env/index.js +++ b/src/setup_node_env/index.js @@ -7,4 +7,5 @@ */ require('./no_transpilation'); +// eslint-disable-next-line import/no-extraneous-dependencies require('@kbn/optimizer').registerNodeAutoTranspilation(); diff --git a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js b/test/api_integration/apis/console/index.ts similarity index 50% rename from src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js rename to test/api_integration/apis/console/index.ts index af8404eb6da92..ad4f8256f97ad 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js +++ b/test/api_integration/apis/console/index.ts @@ -6,12 +6,10 @@ * Side Public License, v 1. */ -import React, { useContext } from 'react'; -import { CoreStartContext } from '../contexts/query_input_bar_context'; -import { QueryStringInput } from '../../../../../plugins/data/public'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export function QueryBarWrapper(props) { - const coreStartContext = useContext(CoreStartContext); - - return ; +export default function ({ loadTestFile }: FtrProviderContext) { + describe('core', () => { + loadTestFile(require.resolve('./proxy_route')); + }); } diff --git a/test/api_integration/apis/console/proxy_route.ts b/test/api_integration/apis/console/proxy_route.ts new file mode 100644 index 0000000000000..d8a5f57a41a6e --- /dev/null +++ b/test/api_integration/apis/console/proxy_route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('POST /api/console/proxy', () => { + describe('system indices behavior', () => { + it('returns warning header when making requests to .kibana index', async () => { + return await supertest + .post('/api/console/proxy?method=GET&path=/.kibana/_settings') + .set('kbn-xsrf', 'true') + .then((response) => { + expect(response.header).to.have.property('warning'); + const { warning } = response.header as { warning: string }; + expect(warning.startsWith('299')).to.be(true); + expect(warning.includes('system indices')).to.be(true); + }); + }); + + it('does not forward x-elastic-product-origin', async () => { + // If we pass the header and we still get the warning back, we assume that the header was not forwarded. + return await supertest + .post('/api/console/proxy?method=GET&path=/.kibana/_settings') + .set('kbn-xsrf', 'true') + .set('x-elastic-product-origin', 'kibana') + .then((response) => { + expect(response.header).to.have.property('warning'); + const { warning } = response.header as { warning: string }; + expect(warning.startsWith('299')).to.be(true); + expect(warning.includes('system indices')).to.be(true); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index 33495ad2c604b..0d87569cb8b97 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', () => { + loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index a7b4da566b143..d0a09ee58d335 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -156,7 +156,7 @@ export default function ({ getService }: FtrProviderContext) { describe('application usage limits', () => { function createSavedObject(viewId?: string) { return supertest - .post('/api/saved_objects/application_usage_transactional') + .post('/api/saved_objects/application_usage_daily') .send({ attributes: { appId: 'test-app', @@ -184,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { await Promise.all( savedObjectIds.map((savedObjectId) => { return supertest - .delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`) + .delete(`/api/saved_objects/application_usage_daily/${savedObjectId}`) .expect(200); }) ); @@ -230,7 +230,7 @@ export default function ({ getService }: FtrProviderContext) { .post('/api/saved_objects/_bulk_create') .send( new Array(10001).fill(0).map(() => ({ - type: 'application_usage_transactional', + type: 'application_usage_daily', attributes: { appId: 'test-app', minutesOnScreen: 1, @@ -248,13 +248,12 @@ export default function ({ getService }: FtrProviderContext) { // The SavedObjects API does not allow bulk deleting, and deleting one by one takes ages and the tests timeout await es.deleteByQuery({ index: '.kibana', - body: { query: { term: { type: 'application_usage_transactional' } } }, + body: { query: { term: { type: 'application_usage_daily' } } }, conflicts: 'proceed', }); }); - // flaky https://github.com/elastic/kibana/issues/94513 - it.skip("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { + it("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { const stats = await retrieveTelemetry(supertest); expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ 'test-app': { diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index def175474d40e..cc62608fbde6d 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -128,7 +128,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return actualCount === expectedCount; }); const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); - expect(Math.round(newDurationHours)).to.be(26); + expect(Math.round(newDurationHours)).to.be(27); await retry.waitFor('doc table to contain the right search result', async () => { const rowData = await PageObjects.discover.getDocTableField(1); diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 2a6096f8d1a78..72deb74459ab9 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -22,7 +22,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); - describe('discover histogram', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/94532 + describe.skip('discover histogram', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('long_window_logstash'); diff --git a/test/functional/apps/discover/_huge_fields.ts b/test/functional/apps/discover/_huge_fields.ts new file mode 100644 index 0000000000000..8cb39feb2e6bb --- /dev/null +++ b/test/functional/apps/discover/_huge_fields.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'home', 'settings', 'discover', 'timePicker']); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + + describe('test large number of fields in sidebar', function () { + before(async function () { + await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], false); + await esArchiver.loadIfNeeded('large_fields'); + await PageObjects.settings.navigateTo(); + await kibanaServer.uiSettings.update({ + 'timepicker:timeDefaults': `{ "from": "2016-10-05T00:00:00", "to": "2016-10-06T00:00:00"}`, + }); + await PageObjects.settings.createIndexPattern('*huge*', 'date', true); + await PageObjects.common.navigateToApp('discover'); + }); + + it('test_huge data should have expected number of fields', async function () { + await PageObjects.discover.selectIndexPattern('*huge*'); + // initially this field should not be rendered + const fieldExistsBeforeScrolling = await testSubjects.exists('field-myvar1050'); + expect(fieldExistsBeforeScrolling).to.be(false); + // scrolling down a little, should render this field + await testSubjects.scrollIntoView('fieldToggle-myvar1029'); + const fieldExistsAfterScrolling = await testSubjects.exists('field-myvar1050'); + expect(fieldExistsAfterScrolling).to.be(true); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + await esArchiver.unload('large_fields'); + await kibanaServer.uiSettings.replace({}); + }); + }); +} diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 23f3af37bbdf6..9726b097c8f62 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); - // FLAKY: https://github.com/elastic/kibana/issues/89477 - describe.skip('saved queries saved objects', function describeIndexTests() { + describe('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); @@ -120,6 +119,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('does not allow saving a query with a non-unique name', async () => { + // this check allows this test to run stand alone, also should fix occacional flakiness + const savedQueryExists = await savedQueryManagementComponent.savedQueryExist('OkResponse'); + if (!savedQueryExists) { + await savedQueryManagementComponent.saveNewQuery( + 'OkResponse', + '200 responses for .jpg over 24 hours', + true, + true + ); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + } await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); }); diff --git a/test/functional/apps/discover/index.ts b/test/functional/apps/discover/index.ts index 5c319312c8137..e526cdaccbd4c 100644 --- a/test/functional/apps/discover/index.ts +++ b/test/functional/apps/discover/index.ts @@ -47,5 +47,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_doc_navigation')); loadTestFile(require.resolve('./_data_grid_doc_table')); loadTestFile(require.resolve('./_indexpattern_with_unmapped_fields')); + loadTestFile(require.resolve('./_huge_fields')); }); } diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index 4b3533f20c8dc..e3ff1819aed13 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); + const testSubjects = getService('testSubjects'); describe('runtime fields', function () { this.tags(['skipFirefox']); @@ -47,6 +48,20 @@ export default function ({ getService, getPageObjects }) { expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); }); }); + + it('should modify runtime field', async function () { + await PageObjects.settings.filterField(fieldName); + await testSubjects.click('editFieldFormat'); + await PageObjects.settings.setFieldType('Long'); + await PageObjects.settings.changeFieldScript('emit(6);'); + await PageObjects.settings.clickSaveField(); + await PageObjects.settings.confirmSave(); + }); + + it('should delete runtime field', async function () { + await testSubjects.click('deleteField'); + await PageObjects.settings.confirmDelete(); + }); }); }); } diff --git a/test/functional/apps/visualize/_heatmap_chart.ts b/test/functional/apps/visualize/_heatmap_chart.ts index 660f45179631e..79a9a6cbd5aca 100644 --- a/test/functional/apps/visualize/_heatmap_chart.ts +++ b/test/functional/apps/visualize/_heatmap_chart.ts @@ -15,7 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - describe('heatmap chart', function indexPatternCreation() { + // FLAKY: https://github.com/elastic/kibana/issues/95642 + describe.skip('heatmap chart', function indexPatternCreation() { const vizName1 = 'Visualization HeatmapChart'; before(async function () { diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index ba500904d75c7..6b0080c3856fd 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -43,6 +43,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('metric'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('metric'); }); it('should not have inspector enabled', async () => { @@ -81,12 +84,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.checkGaugeTabIsPresent(); }); + it('should "Entire time range" selected as timerange mode for new visualization', async () => { + await PageObjects.visualBuilder.clickPanelOptions('gauge'); + await PageObjects.visualBuilder.checkSelectedDataTimerangeMode('Entire time range'); + await PageObjects.visualBuilder.clickDataTab('gauge'); + }); + it('should verify gauge label and count display', async () => { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); const labelString = await PageObjects.visualBuilder.getGaugeLabel(); expect(labelString).to.be('Count'); const gaugeCount = await PageObjects.visualBuilder.getGaugeCount(); - expect(gaugeCount).to.be('156'); + expect(gaugeCount).to.be('13,830'); }); }); @@ -95,6 +104,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickTopN(); await PageObjects.visualBuilder.checkTopNTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('topN'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('topN'); }); it('should verify topN label and count display', async () => { @@ -107,33 +119,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('switch index patterns', () => { + before(async () => { + await esArchiver.loadIfNeeded('index_pattern_without_timefield'); + }); + beforeEach(async () => { - log.debug('Load kibana_sample_data_flights data'); - await esArchiver.loadIfNeeded('kibana_sample_data_flights'); await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('metric'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('metric'); + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 22, 2019 @ 00:00:00.000', + 'Sep 23, 2019 @ 00:00:00.000' + ); }); + after(async () => { await security.testUser.restoreDefaults(); - await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('index_pattern_without_timefield'); }); - it('should be able to switch between index patterns', async () => { - const value = await PageObjects.visualBuilder.getMetricValue(); - expect(value).to.eql('156'); + const switchIndexTest = async (useKibanaIndexes: boolean) => { await PageObjects.visualBuilder.clickPanelOptions('metric'); - const fromTime = 'Oct 22, 2018 @ 00:00:00.000'; - const toTime = 'Oct 28, 2018 @ 23:59:59.999'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.visualBuilder.setIndexPatternValue('', false); + + const value = await PageObjects.visualBuilder.getMetricValue(); + expect(value).to.eql('0'); + // Sometimes popovers take some time to appear in Firefox (#71979) await retry.tryForTime(20000, async () => { - await PageObjects.visualBuilder.setIndexPatternValue('kibana_sample_data_flights'); + await PageObjects.visualBuilder.setIndexPatternValue('with-timefield', useKibanaIndexes); await PageObjects.visualBuilder.waitForIndexPatternTimeFieldOptionsLoaded(); await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); }); const newValue = await PageObjects.visualBuilder.getMetricValue(); - expect(newValue).to.eql('18'); + expect(newValue).to.eql('1'); + }; + + it('should be able to switch using text mode selection', async () => { + await switchIndexTest(false); + }); + + it('should be able to switch combo box mode selection', async () => { + await switchIndexTest(true); }); }); diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index caf9cab8b703a..b61fbf967a9bd 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -37,6 +37,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'Sep 22, 2015 @ 06:00:00.000', 'Sep 22, 2015 @ 11:00:00.000' ); + await visualBuilder.markdownSwitchSubTab('options'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.markdownSwitchSubTab('markdown'); }); it('should render subtabs and table variables markdown components', async () => { diff --git a/test/functional/apps/visualize/_tsvb_table.ts b/test/functional/apps/visualize/_tsvb_table.ts index dfa232b6e527d..36c0e26430ff5 100644 --- a/test/functional/apps/visualize/_tsvb_table.ts +++ b/test/functional/apps/visualize/_tsvb_table.ts @@ -24,6 +24,9 @@ export default function ({ getPageObjects }: FtrProviderContext) { await visualBuilder.clickTable(); await visualBuilder.checkTableTabIsPresent(); + await visualBuilder.clickPanelOptions('table'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.clickDataTab('table'); await visualBuilder.selectGroupByField('machine.os.raw'); await visualBuilder.setColumnLabelValue('OS'); await visChart.waitForVisualizationRenderingStabilized(); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index c6412f55dffbf..6d9641a1a920e 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -463,6 +463,21 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo async getWelcomeText() { return await testSubjects.getVisibleText('global-banner-item'); } + + /** + * Clicks on an element, and validates that the desired effect has taken place + * by confirming the existence of a validator + */ + async clickAndValidate( + clickTarget: string, + validator: string, + isValidatorCssString: boolean = false, + topOffset?: number + ) { + await testSubjects.click(clickTarget, undefined, topOffset); + const validate = isValidatorCssString ? find.byCssSelector : testSubjects.exists; + await validate(validator); + } } return new CommonPage(); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 4151a8c1a1893..14bd002ec9487 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -502,6 +502,16 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await this.closeIndexPatternFieldEditor(); } + public async confirmSave() { + await testSubjects.setValue('saveModalConfirmText', 'change'); + await testSubjects.click('confirmModalConfirmButton'); + } + + public async confirmDelete() { + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); + } + async closeIndexPatternFieldEditor() { await retry.waitFor('field editor flyout to close', async () => { return !(await testSubjects.exists('euiFlyoutCloseButton')); @@ -543,6 +553,17 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider browser.pressKeys(script); } + async changeFieldScript(script: string) { + log.debug('set script = ' + script); + const formatRow = await testSubjects.find('valueRow'); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + browser.pressKeys(browser.keys.DELETE.repeat(30)); + browser.pressKeys(script); + } + async clickAddScriptedField() { log.debug('click Add Scripted Field'); await testSubjects.click('addScriptedFieldLink'); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index d7bb84394ae3c..fbb2b101eb3af 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -431,10 +431,39 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro await PageObjects.header.waitUntilLoadingHasFinished(); } - public async setIndexPatternValue(value: string) { - const el = await testSubjects.find('metricsIndexPatternInput'); - await el.clearValue(); - await el.type(value, { charByChar: true }); + public async clickDataTab(tabName: string) { + await testSubjects.click(`${tabName}EditorDataBtn`); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { + await testSubjects.click('switchIndexPatternSelectionModePopover'); + await testSubjects.setEuiSwitch( + 'switchIndexPatternSelectionMode', + useKibanaIndices ? 'check' : 'uncheck' + ); + } + + public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { + const metricsIndexPatternInput = 'metricsIndexPatternInput'; + + if (useKibanaIndices !== undefined) { + await this.switchIndexPatternSelectionMode(useKibanaIndices); + } + + if (useKibanaIndices === false) { + const el = await testSubjects.find(metricsIndexPatternInput); + await el.clearValue(); + if (value) { + await el.type(value, { charByChar: true }); + } + } else { + await comboBox.clearInputField(metricsIndexPatternInput); + if (value) { + await comboBox.setCustom(metricsIndexPatternInput, value); + } + } + await PageObjects.header.waitUntilLoadingHasFinished(); } @@ -614,6 +643,16 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro ); return await comboBox.isOptionSelected(groupBy, value); } + + public async setMetricsDataTimerangeMode(value: string) { + const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); + return await comboBox.setElement(dataTimeRangeMode, value); + } + + public async checkSelectedDataTimerangeMode(value: string) { + const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); + return await comboBox.isOptionSelected(dataTimeRangeMode, value); + } } return new VisualBuilderPage(); diff --git a/test/functional/screenshots/baseline/tsvb_dashboard.png b/test/functional/screenshots/baseline/tsvb_dashboard.png index e0d79c7234f6a..4280199e77d11 100644 Binary files a/test/functional/screenshots/baseline/tsvb_dashboard.png and b/test/functional/screenshots/baseline/tsvb_dashboard.png differ diff --git a/test/functional/services/common/find.ts b/test/functional/services/common/find.ts index 2a86efad1ea9d..0cd4c14683f6e 100644 --- a/test/functional/services/common/find.ts +++ b/test/functional/services/common/find.ts @@ -79,11 +79,11 @@ export async function FindProvider({ getService }: FtrProviderContext) { return wrap(await driver.switchTo().activeElement()); } - public async setValue(selector: string, text: string): Promise { + public async setValue(selector: string, text: string, topOffset?: number): Promise { log.debug(`Find.setValue('${selector}', '${text}')`); return await retry.try(async () => { const element = await this.byCssSelector(selector); - await element.click(); + await element.click(topOffset); // in case the input element is actually a child of the testSubject, we // call clearValue() and type() on the element that is focused after @@ -413,14 +413,15 @@ export async function FindProvider({ getService }: FtrProviderContext) { public async clickByCssSelector( selector: string, - timeout: number = defaultFindTimeout + timeout: number = defaultFindTimeout, + topOffset?: number ): Promise { log.debug(`Find.clickByCssSelector('${selector}') with timeout=${timeout}`); await retry.try(async () => { const element = await this.byCssSelector(selector, timeout); if (element) { // await element.moveMouseTo(); - await element.click(); + await element.click(topOffset); } else { throw new Error(`Element with css='${selector}' is not found`); } diff --git a/test/functional/services/common/test_subjects.ts b/test/functional/services/common/test_subjects.ts index 28b37d9576e8c..111206ec9eafe 100644 --- a/test/functional/services/common/test_subjects.ts +++ b/test/functional/services/common/test_subjects.ts @@ -100,9 +100,13 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { await find.clickByCssSelectorWhenNotDisabled(testSubjSelector(selector), { timeout }); } - public async click(selector: string, timeout: number = FIND_TIME): Promise { + public async click( + selector: string, + timeout: number = FIND_TIME, + topOffset?: number + ): Promise { log.debug(`TestSubjects.click(${selector})`); - await find.clickByCssSelector(testSubjSelector(selector), timeout); + await find.clickByCssSelector(testSubjSelector(selector), timeout, topOffset); } public async doubleClick(selector: string, timeout: number = FIND_TIME): Promise { @@ -187,12 +191,13 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { public async setValue( selector: string, text: string, - options: SetValueOptions = {} + options: SetValueOptions = {}, + topOffset?: number ): Promise { return await retry.try(async () => { const { clearWithKeyboard = false, typeCharByChar = false } = options; log.debug(`TestSubjects.setValue(${selector}, ${text})`); - await this.click(selector); + await this.click(selector, undefined, topOffset); // in case the input element is actually a child of the testSubject, we // call clearValue() and type() on the element that is focused after // clicking on the testSubject diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index 5cd1f2c4f6202..7d6dad4f7858e 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function FieldEditorProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); - const retry = getService('retry'); const testSubjects = getService('testSubjects'); class FieldEditor { @@ -33,10 +32,17 @@ export function FieldEditorProvider({ getService }: FtrProviderContext) { await browser.pressKeys(script); } public async save() { - await retry.try(async () => { - await testSubjects.click('fieldSaveButton'); - await testSubjects.missingOrFail('fieldSaveButton', { timeout: 2000 }); - }); + await testSubjects.click('fieldSaveButton'); + } + + public async confirmSave() { + await testSubjects.setValue('saveModalConfirmText', 'change'); + await testSubjects.click('confirmModalConfirmButton'); + } + + public async confirmDelete() { + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); } } diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index 1a45aee877e1f..b1561b29342da 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -182,9 +182,9 @@ export class WebElementWrapper { * * @return {Promise} */ - public async click() { + public async click(topOffset?: number) { await this.retryCall(async function click(wrapper) { - await wrapper.scrollIntoViewIfNecessary(); + await wrapper.scrollIntoViewIfNecessary(topOffset); await wrapper._webElement.click(); }); } @@ -693,11 +693,11 @@ export class WebElementWrapper { * @nonstandard * @return {Promise} */ - public async scrollIntoViewIfNecessary(): Promise { + public async scrollIntoViewIfNecessary(topOffset?: number): Promise { await this.driver.executeScript( scrollIntoViewIfNecessary, this._webElement, - this.fixedHeaderHeight + topOffset || this.fixedHeaderHeight ); } diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index a39032af43295..7398e6ca8c12e 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -139,6 +139,13 @@ export function SavedQueryManagementComponentProvider({ await testSubjects.click('savedQueryFormSaveButton'); } + async savedQueryExist(title: string) { + await this.openSavedQueryManagementComponent(); + const exists = testSubjects.exists(`~load-saved-query-${title}-button`); + await this.closeSavedQueryManagementComponent(); + return exists; + } + async savedQueryExistOrFail(title: string) { await this.openSavedQueryManagementComponent(); await testSubjects.existOrFail(`~load-saved-query-${title}-button`); diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index f8a91e3a0a67a..c338bbc998c49 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -23,6 +23,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { __servicenow: { type: 'long' }, __jira: { type: 'long' }, __resilient: { type: 'long' }, + __teams: { type: 'long' }, }; export function createActionsUsageCollector( diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts index 884120d3d03df..59aeb4854d9f0 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts @@ -16,6 +16,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { // Known alerts (searching the use of the alerts API `registerType`: // Built-in '__index-threshold': { type: 'long' }, + '__es-query': { type: 'long' }, // APM apm__error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention apm__transaction_error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention @@ -41,6 +42,10 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { xpack__uptime__alerts__monitorStatus: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__tls: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__durationAnomaly: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + // Maps + '__geo-containment': { type: 'long' }, + // ML + xpack_ml_anomaly_detection_alert: { type: 'long' }, }; export function createAlertsUsageCollector( diff --git a/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts new file mode 100644 index 0000000000000..fb7ef6d36ce25 --- /dev/null +++ b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint( + endpoint: string, + pathParams: Record = {} +) { + const [method, rawPathname] = endpoint.split(' '); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method: parseMethod(method), pathname }; +} + +export function parseMethod(method: string) { + const res = method.trim().toLowerCase() as Method; + + if (!['get', 'post', 'put', 'delete'].includes(res)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return res; +} diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts index 3316c74d52e38..4212e0430ff5f 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts @@ -45,10 +45,10 @@ describe('strictKeysRt', () => { { type: t.intersection([ t.type({ query: t.type({ bar: t.string }) }), - t.partial({ query: t.partial({ _debug: t.boolean }) }), + t.partial({ query: t.partial({ _inspect: t.boolean }) }), ]), - passes: [{ query: { bar: '', _debug: true } }], - fails: [{ query: { _debug: true } }], + passes: [{ query: { bar: '', _inspect: true } }], + fails: [{ query: { _inspect: true } }], }, ]; @@ -91,12 +91,12 @@ describe('strictKeysRt', () => { } as Record); const typeB = t.partial({ - query: t.partial({ _debug: jsonRt.pipe(t.boolean) }), + query: t.partial({ _inspect: jsonRt.pipe(t.boolean) }), }); const value = { query: { - _debug: 'true', + _inspect: 'true', filterNames: JSON.stringify(['host', 'agentName']), }, }; diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index b785fcc7dab08..7df6ca343426c 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -8,7 +8,7 @@ import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; -import { AppMountParameters, CoreStart, HttpSetup } from 'src/core/public'; +import { AppMountParameters, CoreStart } from 'src/core/public'; import { mockApmPluginContextValue } from '../context/apm_plugin/mock_apm_plugin_context'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; @@ -72,7 +72,7 @@ describe('renderApp', () => { embeddable, }; jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); - createCallApmApi((core.http as unknown) as HttpSetup); + createCallApmApi((core as unknown) as CoreStart); jest .spyOn(window.console, 'warn') diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 8ea4593bb89a7..787b15d0a5675 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -118,7 +118,7 @@ export const renderApp = ( ) => { const { element } = appMountParameters; - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 64600dd500bd5..bc14bc1531686 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -120,7 +120,7 @@ export const renderApp = ( // render APM feedback link in global help menu setHelpExtension(core); setReadonlyBadge(core); - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index 29f74b26d310c..fdfed6eb0d685 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -107,7 +107,11 @@ export function ErrorCountAlertTrigger(props: Props) { ]; const chartPreview = ( - + ); return ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 11aab788ec7f4..b4c78b54f329b 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -13,7 +13,6 @@ import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../typings/timeseries'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; @@ -116,9 +115,9 @@ export function TransactionDurationAlertTrigger(props: Props) { ] ); - const maxY = getMaxY([ - { data: data ?? [] } as TimeSeries<{ x: number; y: number | null }>, - ]); + const latencyChartPreview = data?.latencyChartPreview ?? []; + + const maxY = getMaxY([{ data: latencyChartPreview }]); const formatter = getDurationFormatter(maxY); const yTickFormat = getResponseTimeTickFormatter(formatter); @@ -127,7 +126,7 @@ export function TransactionDurationAlertTrigger(props: Props) { const chartPreview = ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index de30af4a4707f..c6f9c4efd98b6 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -132,7 +132,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { const chartPreview = ( asPercent(d, 1)} threshold={thresholdAsPercent} /> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx index 6c94b895f6924..db5932a96fb12 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -35,7 +35,7 @@ export function BreakdownSeries({ ? EUI_CHARTS_THEME_DARK : EUI_CHARTS_THEME_LIGHT; - const { data, status } = useBreakdowns({ + const { breakdowns, status } = useBreakdowns({ field, value, percentileRange, @@ -49,7 +49,7 @@ export function BreakdownSeries({ // so don't user that here return ( <> - {data?.map(({ data: seriesData, name }, sortIndex) => ( + {breakdowns.map(({ data: seriesData, name }, sortIndex) => (
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts index 5af7f0682db19..e21aaa08c432d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -17,12 +17,10 @@ interface Props { export const useBreakdowns = ({ percentileRange, field, value }: Props) => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end, searchTerm } = urlParams; - const { min: minP, max: maxP } = percentileRange ?? {}; - return useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (start && end && field && value) { return callApmApi({ @@ -47,4 +45,6 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => { }, [end, start, uiFilters, field, value, minP, maxP, searchTerm] ); + + return { breakdowns: data?.pageLoadDistBreakdown ?? [], status }; }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index e3e2a979c48d3..d04bcb79a53e1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -38,6 +38,7 @@ export function MainFilters() { [start, end] ); + const rumServiceNames = data?.rumServices ?? []; const { isSmall } = useBreakPoints(); // on mobile we want it to take full width @@ -48,7 +49,7 @@ export function MainFilters() { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts index c40f6ba2b8850..8ae4c9dc0e01d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts @@ -68,7 +68,7 @@ export function useLocalUIFilters({ }); }; - const { data = getInitialData(filterNames), status } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (shouldFetch && urlParams.start && urlParams.end) { return callApmApi({ @@ -96,7 +96,8 @@ export function useLocalUIFilters({ ] ); - const filters = data.map((filter) => ({ + const localUiFilters = data?.localUiFilters ?? getInitialData(filterNames); + const filters = localUiFilters.map((filter) => ({ ...filter, value: values[filter.name] || [], })); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts index 5b448871804eb..f932cec3cacb6 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts @@ -11,9 +11,9 @@ import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plug import { FetchOptions } from '../../../../../common/fetch_options'; export function useCallApi() { - const { http } = useApmPluginContext().core; + const { core } = useApmPluginContext(); return useMemo(() => { - return (options: FetchOptions) => callApi(http, options); - }, [http]); + return (options: FetchOptions) => callApi(core, options); + }, [core]); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index d754710dc84fa..ac1846155569a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -6,7 +6,7 @@ */ import cytoscape from 'cytoscape'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import React, { ComponentType } from 'react'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; @@ -21,19 +21,21 @@ export default { component: Popover, decorators: [ (Story: ComponentType) => { - const httpMock = ({ - get: async () => ({ - avgCpuUsage: 0.32809666568309237, - avgErrorRate: 0.556068173242986, - avgMemoryUsage: 0.5504868173242986, - transactionStats: { - avgRequestsPerMinute: 164.47222031860858, - avgTransactionDuration: 61634.38905590272, - }, - }), - } as unknown) as HttpSetup; + const coreMock = ({ + http: { + get: async () => ({ + avgCpuUsage: 0.32809666568309237, + avgErrorRate: 0.556068173242986, + avgMemoryUsage: 0.5504868173242986, + transactionStats: { + avgRequestsPerMinute: 164.47222031860858, + avgTransactionDuration: 61634.38905590272, + }, + }), + }, + } as unknown) as CoreStart; - createCallApmApi(httpMock); + createCallApmApi(coreMock); return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx index e762f517ce1b5..71355a84d28d4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -33,7 +33,7 @@ interface Props { } export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + const { data: serviceNamesData, status: serviceNamesStatus } = useFetcher( (callApmApi) => { return callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration/services', @@ -43,8 +43,9 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { [], { preservePreviousData: false } ); + const serviceNames = serviceNamesData?.serviceNames ?? []; - const { data: environments = [], status: environmentStatus } = useFetcher( + const { data: environmentsData, status: environmentsStatus } = useFetcher( (callApmApi) => { if (newConfig.service.name) { return callApmApi({ @@ -59,6 +60,8 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { { preservePreviousData: false } ); + const environments = environmentsData?.environments ?? []; + const { status: agentNameStatus } = useFetcher( async (callApmApi) => { const serviceName = newConfig.service.name; @@ -153,11 +156,11 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { 'xpack.apm.agentConfig.servicePage.environment.fieldLabel', { defaultMessage: 'Service environment' } )} - isLoading={environmentStatus === FETCH_STATUS.LOADING} + isLoading={environmentsStatus === FETCH_STATUS.LOADING} options={environmentOptions} value={newConfig.service.environment} disabled={ - !newConfig.service.name || environmentStatus === FETCH_STATUS.LOADING + !newConfig.service.name || environmentsStatus === FETCH_STATUS.LOADING } onChange={(e) => { e.preventDefault(); diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 4d2754a677bf7..cd5fa5db89a31 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -7,7 +7,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; import { AgentConfiguration } from '../../../../../../common/agent_configuration/configuration_types'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; @@ -23,10 +23,10 @@ storiesOf( module ) .addDecorator((storyFn) => { - const httpMock = {}; + const coreMock = ({} as unknown) as CoreStart; // mock - createCallApmApi((httpMock as unknown) as HttpSetup); + createCallApmApi(coreMock); const contextMock = { core: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 081a3dbc907c5..3e3bc892e6518 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -16,7 +16,7 @@ import { } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { config: Config; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx index bef0dfc22280c..c098be41968dd 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -32,15 +32,19 @@ import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; import { ConfirmDeleteModal } from './ConfirmDeleteModal'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { status: FETCH_STATUS; - data: Config[]; + configurations: Config[]; refetch: () => void; } -export function AgentConfigurationList({ status, data, refetch }: Props) { +export function AgentConfigurationList({ + status, + configurations, + refetch, +}: Props) { const { core } = useApmPluginContext(); const canSave = core.application.capabilities.apm.save; const { basePath } = core.http; @@ -113,7 +117,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { return failurePrompt; } - if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + if (status === FETCH_STATUS.SUCCESS && isEmpty(configurations)) { return emptyStatePrompt; } @@ -231,7 +235,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { } columns={columns} - items={data} + items={configurations} initialSortField="service.name" initialSortDirection="asc" initialPageSize={20} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 8aa0c35f36717..3225951fd6c70 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -25,8 +25,10 @@ import { useFetcher } from '../../../../hooks/use_fetcher'; import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; import { AgentConfigurationList } from './List'; +const INITIAL_DATA = { configurations: [] }; + export function AgentConfigurations() { - const { refetch, data = [], status } = useFetcher( + const { refetch, data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration' }), [], @@ -36,7 +38,7 @@ export function AgentConfigurations() { useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); - const hasConfigurations = !isEmpty(data); + const hasConfigurations = !isEmpty(data.configurations); return ( <> @@ -72,7 +74,11 @@ export function AgentConfigurations() { - + ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 9722c99990e3f..9d2b4bba22afb 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -24,7 +24,10 @@ import React, { useEffect, useState } from 'react'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { clearCache } from '../../../../services/rest/callApi'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; +import { + APIReturnType, + callApmApi, +} from '../../../../services/rest/createCallApmApi'; const APM_INDEX_LABELS = [ { @@ -84,8 +87,10 @@ async function saveApmIndices({ clearCache(); } +type ApiResponse = APIReturnType<`GET /api/apm/settings/apm-index-settings`>; + // avoid infinite loop by initializing the state outside the component -const INITIAL_STATE = [] as []; +const INITIAL_STATE: ApiResponse = { apmIndexSettings: [] }; export function ApmIndices() { const { core } = useApmPluginContext(); @@ -108,7 +113,7 @@ export function ApmIndices() { useEffect(() => { setApmIndices( - data.reduce( + data.apmIndexSettings.reduce( (acc, { configurationName, savedValue }) => ({ ...acc, [configurationName]: savedValue, @@ -190,7 +195,7 @@ export function ApmIndices() { {APM_INDEX_LABELS.map(({ configurationName, label }) => { - const matchedConfiguration = data.find( + const matchedConfiguration = data.apmIndexSettings.find( ({ configurationName: configName }) => configName === configurationName ); 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 0dbc8f6235342..77835afef863a 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 @@ -24,20 +24,12 @@ import { } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CreateEditCustomLinkFlyout/saveCustomLink'; -const data = [ - { - id: '1', - label: 'label 1', - url: 'url 1', - 'service.name': 'opbeans-java', - }, - { - id: '2', - label: 'label 2', - url: 'url 2', - 'transaction.type': 'request', - }, -]; +const data = { + customLinks: [ + { id: '1', label: 'label 1', url: 'url 1', 'service.name': 'opbeans-java' }, + { id: '2', label: 'label 2', url: 'url 2', 'transaction.type': 'request' }, + ], +}; function getMockAPMContext({ canSave }: { canSave: boolean }) { return ({ @@ -69,7 +61,7 @@ describe('CustomLink', () => { describe('empty prompt', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -290,7 +282,7 @@ describe('CustomLink', () => { describe('invalid license', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index 4b4bc2e8feeab..49fa3eab47862 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -35,7 +35,7 @@ export function CustomLinkOverview() { CustomLink | undefined >(); - const { data: customLinks = [], status, refetch } = useFetcher( + const { data, status, refetch } = useFetcher( async (callApmApi) => { if (hasValidLicense) { return callApmApi({ @@ -46,6 +46,8 @@ export function CustomLinkOverview() { [hasValidLicense] ); + const customLinks = data?.customLinks ?? []; + useEffect(() => { if (customLinkSelected) { setIsFlyoutOpen(true); diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 6a11f862994e2..bf9062418313a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -21,6 +21,7 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { ML_ERRORS } from '../../../../../common/anomaly_detection'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; @@ -33,6 +34,10 @@ interface Props { onCreateJobSuccess: () => void; onCancel: () => void; } + +type ApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/environments'>; +const INITIAL_DATA: ApiResponse = { environments: [] }; + export function AddEnvironments({ currentEnvironments, onCreateJobSuccess, @@ -42,7 +47,7 @@ export function AddEnvironments({ const { anomalyDetectionJobsRefetch } = useAnomalyDetectionJobsContext(); const canCreateJob = !!application.capabilities.ml.canCreateJob; const { toasts } = notifications; - const { data = [], status } = useFetcher( + const { data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: `GET /api/apm/settings/anomaly-detection/environments`, @@ -51,7 +56,7 @@ export function AddEnvironments({ { preservePreviousData: false } ); - const environmentOptions = data.map((env) => ({ + const environmentOptions = data.environments.map((env) => ({ label: getEnvironmentLabel(env), value: env, disabled: currentEnvironments.includes(env), diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index 7526eaf1aad64..22a12db680334 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -1722,7 +1722,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` >
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with height specified 1`] = `"
"`; @@ -21,7 +21,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with height specified
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with page specified 1`] = `"
"`; @@ -33,7 +33,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with page specified 2`
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with width and height specified 1`] = `"
"`; @@ -45,7 +45,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with width and height
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; exports[`Canvas Shareable Workpad API Placed successfully with width specified 1`] = `"
"`; @@ -57,5 +57,5 @@ exports[`Canvas Shareable Workpad API Placed successfully with width specified 2
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__snapshots__/app.test.tsx.snap b/x-pack/plugins/canvas/shareable_runtime/components/__snapshots__/app.test.tsx.snap index 79b4857068a0f..5c431dee43fe6 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__snapshots__/app.test.tsx.snap +++ b/x-pack/plugins/canvas/shareable_runtime/components/__snapshots__/app.test.tsx.snap @@ -7,5 +7,5 @@ exports[` App renders properly 1`] = `
markdown mock
markdown mock
My Canvas Workpad
" +
markdown mock
My Canvas Workpad
" `; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot index 6a2c2ca3abd21..3432e479bff97 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot +++ b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot @@ -1374,7 +1374,7 @@ exports[`Storyshots shareables/Canvas component 1`] = ` >
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__stories__/__snapshots__/settings.stories.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__stories__/__snapshots__/settings.stories.storyshot index 8f37b253c6352..a522684a978cf 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__stories__/__snapshots__/settings.stories.storyshot +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__stories__/__snapshots__/settings.stories.storyshot @@ -23,18 +23,13 @@ exports[`Storyshots shareables/Footer/Settings component 1`] = `
- -
+ + + +
- + @@ -594,45 +585,36 @@ exports[`extend index management ilm summary extension should return extension w id="phaseExecutionPopover" isOpen={false} key="phaseExecutionPopover" - ownFocus={false} + ownFocus={true} panelPaddingSize="m" > -
-
- - - -
+ Show definition + + +
-
+ diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index ba2ec28bf6bc1..3db0aa43e0e87 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -47,10 +47,10 @@ Array [ exports[`policy table should show empty state when there are not any policies 1`] = `
(props: any) => ( diff --git a/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts new file mode 100644 index 0000000000000..ad4b2963a41bd --- /dev/null +++ b/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogSourceConfigurationProperties } from '../http_api/log_sources'; + +// NOTE: Type will change, see below. +type ResolvedLogsSourceConfiguration = LogSourceConfigurationProperties; + +// NOTE: This will handle real resolution for https://github.com/elastic/kibana/issues/92650, via the index patterns service, but for now just +// hands back properties from the saved object (and therefore looks pointless...). +export const resolveLogSourceConfiguration = ( + sourceConfiguration: LogSourceConfigurationProperties +): ResolvedLogsSourceConfiguration => { + return sourceConfiguration; +}; diff --git a/x-pack/plugins/infra/common/metrics_sources/index.ts b/x-pack/plugins/infra/common/metrics_sources/index.ts new file mode 100644 index 0000000000000..a697c65e5a0aa --- /dev/null +++ b/x-pack/plugins/infra/common/metrics_sources/index.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { omit } from 'lodash'; +import { + SourceConfigurationRT, + SourceStatusRuntimeType, +} from '../source_configuration/source_configuration'; +import { DeepPartial } from '../utility_types'; + +/** + * Properties specific to the Metrics Source Configuration. + */ +export const metricsSourceConfigurationPropertiesRT = rt.strict({ + name: SourceConfigurationRT.props.name, + description: SourceConfigurationRT.props.description, + metricAlias: SourceConfigurationRT.props.metricAlias, + inventoryDefaultView: SourceConfigurationRT.props.inventoryDefaultView, + metricsExplorerDefaultView: SourceConfigurationRT.props.metricsExplorerDefaultView, + fields: rt.strict(omit(SourceConfigurationRT.props.fields.props, 'message')), + anomalyThreshold: rt.number, +}); + +export type MetricsSourceConfigurationProperties = rt.TypeOf< + typeof metricsSourceConfigurationPropertiesRT +>; + +export const partialMetricsSourceConfigurationPropertiesRT = rt.partial({ + ...metricsSourceConfigurationPropertiesRT.type.props, + fields: rt.partial({ + ...metricsSourceConfigurationPropertiesRT.type.props.fields.type.props, + }), +}); + +export type PartialMetricsSourceConfigurationProperties = rt.TypeOf< + typeof partialMetricsSourceConfigurationPropertiesRT +>; + +const metricsSourceConfigurationOriginRT = rt.keyof({ + fallback: null, + internal: null, + stored: null, +}); + +export const metricsSourceStatusRT = rt.strict({ + metricIndicesExist: SourceStatusRuntimeType.props.metricIndicesExist, + indexFields: SourceStatusRuntimeType.props.indexFields, +}); + +export type MetricsSourceStatus = rt.TypeOf; + +export const metricsSourceConfigurationRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + origin: metricsSourceConfigurationOriginRT, + configuration: metricsSourceConfigurationPropertiesRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + status: metricsSourceStatusRT, + }), + ]) +); + +export type MetricsSourceConfiguration = rt.TypeOf; +export type PartialMetricsSourceConfiguration = DeepPartial; + +export const metricsSourceConfigurationResponseRT = rt.type({ + source: metricsSourceConfigurationRT, +}); + +export type MetricsSourceConfigurationResponse = rt.TypeOf< + typeof metricsSourceConfigurationResponseRT +>; diff --git a/x-pack/plugins/infra/common/http_api/source_api.ts b/x-pack/plugins/infra/common/source_configuration/source_configuration.ts similarity index 61% rename from x-pack/plugins/infra/common/http_api/source_api.ts rename to x-pack/plugins/infra/common/source_configuration/source_configuration.ts index f14151531ba35..ad68a7a019848 100644 --- a/x-pack/plugins/infra/common/http_api/source_api.ts +++ b/x-pack/plugins/infra/common/source_configuration/source_configuration.ts @@ -5,8 +5,19 @@ * 2.0. */ +/** + * These are the core source configuration types that represent a Source Configuration in + * it's entirety. There are then subsets of this configuration that form the Logs Source Configuration + * and Metrics Source Configuration. The Logs Source Configuration is further expanded to it's resolved form. + * -> Source Configuration + * -> Logs source configuration + * -> Resolved Logs Source Configuration + * -> Metrics Source Configuration + */ + /* eslint-disable @typescript-eslint/no-empty-interface */ +import { omit } from 'lodash'; import * as rt from 'io-ts'; import moment from 'moment'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -29,121 +40,113 @@ export const TimestampFromString = new rt.Type( ); /** - * Stored source configuration as read from and written to saved objects + * Log columns */ -const SavedSourceConfigurationFieldsRuntimeType = rt.partial({ - container: rt.string, - host: rt.string, - pod: rt.string, - tiebreaker: rt.string, - timestamp: rt.string, -}); - -export type InfraSavedSourceConfigurationFields = rt.TypeOf< - typeof SavedSourceConfigurationFieldColumnRuntimeType ->; - -export const SavedSourceConfigurationTimestampColumnRuntimeType = rt.type({ +export const SourceConfigurationTimestampColumnRuntimeType = rt.type({ timestampColumn: rt.type({ id: rt.string, }), }); export type InfraSourceConfigurationTimestampColumn = rt.TypeOf< - typeof SavedSourceConfigurationTimestampColumnRuntimeType + typeof SourceConfigurationTimestampColumnRuntimeType >; -export const SavedSourceConfigurationMessageColumnRuntimeType = rt.type({ +export const SourceConfigurationMessageColumnRuntimeType = rt.type({ messageColumn: rt.type({ id: rt.string, }), }); export type InfraSourceConfigurationMessageColumn = rt.TypeOf< - typeof SavedSourceConfigurationMessageColumnRuntimeType + typeof SourceConfigurationMessageColumnRuntimeType >; -export const SavedSourceConfigurationFieldColumnRuntimeType = rt.type({ +export const SourceConfigurationFieldColumnRuntimeType = rt.type({ fieldColumn: rt.type({ id: rt.string, field: rt.string, }), }); -export const SavedSourceConfigurationColumnRuntimeType = rt.union([ - SavedSourceConfigurationTimestampColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationFieldColumnRuntimeType, +export type InfraSourceConfigurationFieldColumn = rt.TypeOf< + typeof SourceConfigurationFieldColumnRuntimeType +>; + +export const SourceConfigurationColumnRuntimeType = rt.union([ + SourceConfigurationTimestampColumnRuntimeType, + SourceConfigurationMessageColumnRuntimeType, + SourceConfigurationFieldColumnRuntimeType, ]); -export type InfraSavedSourceConfigurationColumn = rt.TypeOf< - typeof SavedSourceConfigurationColumnRuntimeType ->; +export type InfraSourceConfigurationColumn = rt.TypeOf; -export const SavedSourceConfigurationRuntimeType = rt.partial({ +/** + * Fields + */ + +const SourceConfigurationFieldsRT = rt.type({ + container: rt.string, + host: rt.string, + pod: rt.string, + tiebreaker: rt.string, + timestamp: rt.string, + message: rt.array(rt.string), +}); + +/** + * Properties that represent a full source configuration, which is the result of merging static values with + * saved values. + */ +export const SourceConfigurationRT = rt.type({ name: rt.string, description: rt.string, metricAlias: rt.string, logAlias: rt.string, inventoryDefaultView: rt.string, metricsExplorerDefaultView: rt.string, - fields: SavedSourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), + fields: SourceConfigurationFieldsRT, + logColumns: rt.array(SourceConfigurationColumnRuntimeType), anomalyThreshold: rt.number, }); +/** + * Stored source configuration as read from and written to saved objects + */ +const SavedSourceConfigurationFieldsRuntimeType = rt.partial( + omit(SourceConfigurationFieldsRT.props, ['message']) +); + +export type InfraSavedSourceConfigurationFields = rt.TypeOf< + typeof SavedSourceConfigurationFieldsRuntimeType +>; + +export const SavedSourceConfigurationRuntimeType = rt.intersection([ + rt.partial(omit(SourceConfigurationRT.props, ['fields'])), + rt.partial({ + fields: SavedSourceConfigurationFieldsRuntimeType, + }), +]); + export interface InfraSavedSourceConfiguration extends rt.TypeOf {} export const pickSavedSourceConfiguration = ( value: InfraSourceConfiguration ): InfraSavedSourceConfiguration => { - const { - name, - description, - metricAlias, - logAlias, - fields, - inventoryDefaultView, - metricsExplorerDefaultView, - logColumns, - anomalyThreshold, - } = value; - const { container, host, pod, tiebreaker, timestamp } = fields; - - return { - name, - description, - metricAlias, - logAlias, - inventoryDefaultView, - metricsExplorerDefaultView, - fields: { container, host, pod, tiebreaker, timestamp }, - logColumns, - anomalyThreshold, - }; + return value; }; /** - * Static source configuration as read from the configuration file + * Static source configuration, the result of merging values from the config file and + * hardcoded defaults. */ -const StaticSourceConfigurationFieldsRuntimeType = rt.partial({ - ...SavedSourceConfigurationFieldsRuntimeType.props, - message: rt.array(rt.string), -}); - +const StaticSourceConfigurationFieldsRuntimeType = rt.partial(SourceConfigurationFieldsRT.props); export const StaticSourceConfigurationRuntimeType = rt.partial({ - name: rt.string, - description: rt.string, - metricAlias: rt.string, - logAlias: rt.string, - inventoryDefaultView: rt.string, - metricsExplorerDefaultView: rt.string, + ...SourceConfigurationRT.props, fields: StaticSourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), - anomalyThreshold: rt.number, }); export interface InfraStaticSourceConfiguration @@ -153,18 +156,20 @@ export interface InfraStaticSourceConfiguration * Full source configuration type after all cleanup has been done at the edges */ -const SourceConfigurationFieldsRuntimeType = rt.type({ - ...StaticSourceConfigurationFieldsRuntimeType.props, -}); - -export type InfraSourceConfigurationFields = rt.TypeOf; +export type InfraSourceConfigurationFields = rt.TypeOf; export const SourceConfigurationRuntimeType = rt.type({ - ...SavedSourceConfigurationRuntimeType.props, - fields: SourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), + ...SourceConfigurationRT.props, + fields: SourceConfigurationFieldsRT, + logColumns: rt.array(SourceConfigurationColumnRuntimeType), }); +export interface InfraSourceConfiguration + extends rt.TypeOf {} + +/** + * Source status + */ const SourceStatusFieldRuntimeType = rt.type({ name: rt.string, type: rt.string, @@ -175,12 +180,17 @@ const SourceStatusFieldRuntimeType = rt.type({ export type InfraSourceIndexField = rt.TypeOf; -const SourceStatusRuntimeType = rt.type({ +export const SourceStatusRuntimeType = rt.type({ logIndicesExist: rt.boolean, metricIndicesExist: rt.boolean, indexFields: rt.array(SourceStatusFieldRuntimeType), }); +export interface InfraSourceStatus extends rt.TypeOf {} + +/** + * Source configuration along with source status and metadata + */ export const SourceRuntimeType = rt.intersection([ rt.type({ id: rt.string, @@ -198,11 +208,6 @@ export const SourceRuntimeType = rt.intersection([ }), ]); -export interface InfraSourceStatus extends rt.TypeOf {} - -export interface InfraSourceConfiguration - extends rt.TypeOf {} - export interface InfraSource extends rt.TypeOf {} export const SourceResponseRuntimeType = rt.type({ diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 88d72300c2d6d..b345e138accec 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -17,7 +17,7 @@ import { act } from 'react-dom/test-utils'; import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; import { SnapshotCustomMetricInput } from '../../../../common/http_api/snapshot_api'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index b28c76d1cb374..c4f8b5a615b0f 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -43,7 +43,7 @@ import { AlertTypeParamsExpressionProps, } from '../../../../../triggers_actions_ui/public'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items'; import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; @@ -124,14 +124,13 @@ export const Expressions: React.FC = (props) => { } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index dd4cbe10b74ee..6b99aff9f903d 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { Expression, AlertContextMeta } from './expression'; import { act } from 'react-dom/test-utils'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 12cc2bf9fb3a9..afbd6ffa8b5f7 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -27,7 +27,7 @@ import { AlertTypeParamsExpressionProps, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/types'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { NodeTypeExpression } from './node_type'; @@ -75,12 +75,11 @@ export const Expression: React.FC = (props) => { } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx index a6d74d4f461a6..667f5c061ce48 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -15,7 +15,7 @@ import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 3b8afc173c2bd..8835a7cd55ce8 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -35,7 +35,7 @@ import { import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; import { ExpressionRow } from './expression_row'; @@ -73,14 +73,13 @@ export const Expressions: React.FC = (props) => { const { http, notifications } = useKibanaContextForPlugin().services; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index 7e4209e4253d7..caf8e32814fe5 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -10,7 +10,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { coreMock as mockCoreMock } from 'src/core/public/mocks'; import { MetricExpression } from '../types'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import React from 'react'; import { ExpressionChart } from './expression_chart'; import { act } from 'react-dom/test-utils'; @@ -45,20 +45,17 @@ describe('ExpressionChart', () => { fields: [], }; - const source: InfraSource = { + const source: MetricsSourceConfiguration = { id: 'default', origin: 'fallback', configuration: { name: 'default', description: 'The default configuration', - logColumns: [], metricAlias: 'metricbeat-*', - logAlias: 'filebeat-*', inventoryDefaultView: 'host', metricsExplorerDefaultView: 'host', fields: { timestamp: '@timestamp', - message: ['message'], container: 'container.id', host: 'host.name', pod: 'kubernetes.pod.uid', diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index 2a274c4b6d50f..e5558b961ab20 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -11,7 +11,7 @@ import { first, last } from 'lodash'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { Color } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; @@ -35,7 +35,7 @@ import { ThresholdAnnotations } from '../../common/criterion_preview_chart/thres interface Props { expression: MetricExpression; derivedIndexPattern: IIndexPattern; - source: InfraSource | null; + source: MetricsSourceConfiguration | null; filterQuery?: string; groupBy?: string | string[]; } diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx index 54477a39c2626..90f75e6a94022 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx @@ -13,7 +13,7 @@ import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts index 908372d13b6bc..e3006993216ae 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts @@ -7,7 +7,7 @@ import { IIndexPattern } from 'src/plugins/data/public'; import { useMemo } from 'react'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { MetricExpression } from '../types'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data'; @@ -15,7 +15,7 @@ import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/ export const useMetricsExplorerChartData = ( expression: MetricExpression, derivedIndexPattern: IIndexPattern, - source: InfraSource | null, + source: MetricsSourceConfiguration | null, filterQuery?: string, groupBy?: string | string[] ) => { diff --git a/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx b/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx deleted file mode 100644 index b5b28cb25b83b..0000000000000 --- a/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx +++ /dev/null @@ -1,156 +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, { useCallback, useMemo, useState } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - LogColumnConfiguration, - isTimestampLogColumnConfiguration, - isMessageLogColumnConfiguration, - TimestampLogColumnConfiguration, - MessageLogColumnConfiguration, - FieldLogColumnConfiguration, -} from '../../utils/source_configuration'; - -export interface TimestampLogColumnConfigurationProps { - logColumnConfiguration: TimestampLogColumnConfiguration['timestampColumn']; - remove: () => void; - type: 'timestamp'; -} - -export interface MessageLogColumnConfigurationProps { - logColumnConfiguration: MessageLogColumnConfiguration['messageColumn']; - remove: () => void; - type: 'message'; -} - -export interface FieldLogColumnConfigurationProps { - logColumnConfiguration: FieldLogColumnConfiguration['fieldColumn']; - remove: () => void; - type: 'field'; -} - -export type LogColumnConfigurationProps = - | TimestampLogColumnConfigurationProps - | MessageLogColumnConfigurationProps - | FieldLogColumnConfigurationProps; - -interface FormState { - logColumns: LogColumnConfiguration[]; -} - -type FormStateChanges = Partial; - -export const useLogColumnsConfigurationFormState = ({ - initialFormState = defaultFormState, -}: { - initialFormState?: FormState; -}) => { - const [formStateChanges, setFormStateChanges] = useState({}); - - const resetForm = useCallback(() => setFormStateChanges({}), []); - - const formState = useMemo( - () => ({ - ...initialFormState, - ...formStateChanges, - }), - [initialFormState, formStateChanges] - ); - - const logColumnConfigurationProps = useMemo( - () => - formState.logColumns.map( - (logColumn): LogColumnConfigurationProps => { - const remove = () => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: formState.logColumns.filter((item) => item !== logColumn), - })); - - if (isTimestampLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.timestampColumn, - remove, - type: 'timestamp', - }; - } else if (isMessageLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.messageColumn, - remove, - type: 'message', - }; - } else { - return { - logColumnConfiguration: logColumn.fieldColumn, - remove, - type: 'field', - }; - } - } - ), - [formState.logColumns] - ); - - const addLogColumn = useCallback( - (logColumnConfiguration: LogColumnConfiguration) => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: [...formState.logColumns, logColumnConfiguration], - })), - [formState.logColumns] - ); - - const moveLogColumn = useCallback( - (sourceIndex, destinationIndex) => { - if (destinationIndex >= 0 && sourceIndex <= formState.logColumns.length - 1) { - const newLogColumns = [...formState.logColumns]; - newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]); - setFormStateChanges((changes) => ({ - ...changes, - logColumns: newLogColumns, - })); - } - }, - [formState.logColumns] - ); - - const errors = useMemo( - () => - logColumnConfigurationProps.length <= 0 - ? [ - , - ] - : [], - [logColumnConfigurationProps] - ); - - const isFormValid = useMemo(() => (errors.length <= 0 ? true : false), [errors]); - - const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]); - - return { - addLogColumn, - moveLogColumn, - errors, - logColumnConfigurationProps, - formState, - formStateChanges, - isFormDirty, - isFormValid, - resetForm, - }; -}; - -const defaultFormState: FormState = { - logColumns: [], -}; diff --git a/x-pack/plugins/infra/public/containers/source/index.ts b/x-pack/plugins/infra/public/containers/metrics_source/index.ts similarity index 100% rename from x-pack/plugins/infra/public/containers/source/index.ts rename to x-pack/plugins/infra/public/containers/metrics_source/index.ts diff --git a/x-pack/plugins/infra/public/containers/source/source.tsx b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx similarity index 79% rename from x-pack/plugins/infra/public/containers/source/source.tsx rename to x-pack/plugins/infra/public/containers/metrics_source/source.tsx index 8e2a8f29e03df..b730f8b007e43 100644 --- a/x-pack/plugins/infra/public/containers/source/source.tsx +++ b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx @@ -9,27 +9,25 @@ import createContainer from 'constate'; import { useEffect, useMemo, useState } from 'react'; import { - InfraSavedSourceConfiguration, - InfraSource, - SourceResponse, -} from '../../../common/http_api/source_api'; + MetricsSourceConfigurationResponse, + MetricsSourceConfiguration, + PartialMetricsSourceConfigurationProperties, +} from '../../../common/metrics_sources'; + import { useTrackedPromise } from '../../utils/use_tracked_promise'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const pickIndexPattern = ( - source: InfraSource | undefined, - type: 'logs' | 'metrics' | 'both' + source: MetricsSourceConfiguration | undefined, + type: 'metrics' ) => { if (!source) { return 'unknown-index'; } - if (type === 'logs') { - return source.configuration.logAlias; - } if (type === 'metrics') { return source.configuration.metricAlias; } - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; + return `${source.configuration.metricAlias}`; }; const DEPENDENCY_ERROR_MESSAGE = 'Failed to load source: No fetch client available.'; @@ -39,7 +37,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const fetchService = kibana.services.http?.fetch; const API_URL = `/api/metrics/source/${sourceId}`; - const [source, setSource] = useState(undefined); + const [source, setSource] = useState(undefined); const [loadSourceRequest, loadSource] = useTrackedPromise( { @@ -49,7 +47,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(`${API_URL}/metrics`, { + return await fetchService(`${API_URL}`, { method: 'GET', }); }, @@ -62,12 +60,12 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const [createSourceConfigurationRequest, createSourceConfiguration] = useTrackedPromise( { - createPromise: async (sourceProperties: InfraSavedSourceConfiguration) => { + createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { if (!fetchService) { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(API_URL, { + return await fetchService(API_URL, { method: 'PATCH', body: JSON.stringify(sourceProperties), }); @@ -83,12 +81,12 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const [updateSourceConfigurationRequest, updateSourceConfiguration] = useTrackedPromise( { - createPromise: async (sourceProperties: InfraSavedSourceConfiguration) => { + createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { if (!fetchService) { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(API_URL, { + return await fetchService(API_URL, { method: 'PATCH', body: JSON.stringify(sourceProperties), }); @@ -102,7 +100,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { [fetchService, sourceId] ); - const createDerivedIndexPattern = (type: 'logs' | 'metrics' | 'both') => { + const createDerivedIndexPattern = (type: 'metrics') => { return { fields: source?.status ? source.status.indexFields : [], title: pickIndexPattern(source, type), @@ -129,9 +127,6 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const sourceExists = useMemo(() => (source ? !!source.version : undefined), [source]); - const logIndicesExist = useMemo(() => source && source.status && source.status.logIndicesExist, [ - source, - ]); const metricIndicesExist = useMemo( () => source && source.status && source.status.metricIndicesExist, [source] @@ -144,7 +139,6 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { return { createSourceConfiguration, createDerivedIndexPattern, - logIndicesExist, isLoading, isLoadingSource: loadSourceRequest.state === 'pending', isUninitialized, diff --git a/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts b/x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts similarity index 62% rename from x-pack/plugins/infra/public/containers/source/use_source_via_http.ts rename to x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts index 548e6b8aa9cd9..2947f8fb09847 100644 --- a/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts +++ b/x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts @@ -13,51 +13,47 @@ import createContainer from 'constate'; import { HttpHandler } from 'src/core/public'; import { ToastInput } from 'src/core/public'; import { - SourceResponseRuntimeType, - SourceResponse, - InfraSource, -} from '../../../common/http_api/source_api'; + metricsSourceConfigurationResponseRT, + MetricsSourceConfigurationResponse, + MetricsSourceConfiguration, +} from '../../../common/metrics_sources'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; export const pickIndexPattern = ( - source: InfraSource | undefined, - type: 'logs' | 'metrics' | 'both' + source: MetricsSourceConfiguration | undefined, + type: 'metrics' ) => { if (!source) { return 'unknown-index'; } - if (type === 'logs') { - return source.configuration.logAlias; - } if (type === 'metrics') { return source.configuration.metricAlias; } - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; + return `${source.configuration.metricAlias}`; }; interface Props { sourceId: string; - type: 'logs' | 'metrics' | 'both'; fetch?: HttpHandler; toastWarning?: (input: ToastInput) => void; } -export const useSourceViaHttp = ({ - sourceId = 'default', - type = 'both', - fetch, - toastWarning, -}: Props) => { +export const useSourceViaHttp = ({ sourceId = 'default', fetch, toastWarning }: Props) => { const decodeResponse = (response: any) => { return pipe( - SourceResponseRuntimeType.decode(response), + metricsSourceConfigurationResponseRT.decode(response), fold(throwErrors(createPlainError), identity) ); }; - const { error, loading, response, makeRequest } = useHTTPRequest( - `/api/metrics/source/${sourceId}/${type}`, + const { + error, + loading, + response, + makeRequest, + } = useHTTPRequest( + `/api/metrics/source/${sourceId}`, 'GET', null, decodeResponse, @@ -71,15 +67,12 @@ export const useSourceViaHttp = ({ })(); }, [makeRequest]); - const createDerivedIndexPattern = useCallback( - (indexType: 'logs' | 'metrics' | 'both' = type) => { - return { - fields: response?.source.status ? response.source.status.indexFields : [], - title: pickIndexPattern(response?.source, indexType), - }; - }, - [response, type] - ); + const createDerivedIndexPattern = useCallback(() => { + return { + fields: response?.source.status ? response.source.status.indexFields : [], + title: pickIndexPattern(response?.source, 'metrics'), + }; + }, [response]); const source = useMemo(() => { return response ? response.source : null; diff --git a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx index 4c4835cbe4cdb..56a2a13e31ff7 100644 --- a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx +++ b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx @@ -17,10 +17,10 @@ import { useUrlState } from '../../utils/use_url_state'; import { useFindSavedObject } from '../../hooks/use_find_saved_object'; import { useCreateSavedObject } from '../../hooks/use_create_saved_object'; import { useDeleteSavedObject } from '../../hooks/use_delete_saved_object'; -import { Source } from '../source'; +import { Source } from '../metrics_source'; import { metricsExplorerViewSavedObjectName } from '../../../common/saved_objects/metrics_explorer_view'; import { inventoryViewSavedObjectName } from '../../../common/saved_objects/inventory_view'; -import { useSourceConfigurationFormState } from '../../components/source_configuration/source_configuration_form_state'; +import { useSourceConfigurationFormState } from '../../pages/metrics/settings/source_configuration_form_state'; import { useGetSavedObject } from '../../hooks/use_get_saved_object'; import { useUpdateSavedObject } from '../../hooks/use_update_saved_object'; diff --git a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx index 3b9f0d3e1eae2..f3ca57a40c4c7 100644 --- a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx +++ b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx @@ -9,17 +9,19 @@ import React, { useContext } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { - InfraSavedSourceConfiguration, - InfraSourceConfiguration, -} from '../../../common/http_api/source_api'; + MetricsSourceConfigurationProperties, + PartialMetricsSourceConfigurationProperties, +} from '../../../common/metrics_sources'; import { RendererFunction } from '../../utils/typed_react'; -import { Source } from '../source'; +import { Source } from '../metrics_source'; interface WithSourceProps { children: RendererFunction<{ - configuration?: InfraSourceConfiguration; - create: (sourceProperties: InfraSavedSourceConfiguration) => Promise | undefined; - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + configuration?: MetricsSourceConfigurationProperties; + create: ( + sourceProperties: PartialMetricsSourceConfigurationProperties + ) => Promise | undefined; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; exists?: boolean; hasFailed: boolean; isLoading: boolean; @@ -29,7 +31,9 @@ interface WithSourceProps { metricAlias?: string; metricIndicesExist?: boolean; sourceId: string; - update: (sourceProperties: InfraSavedSourceConfiguration) => Promise | undefined; + update: ( + sourceProperties: PartialMetricsSourceConfigurationProperties + ) => Promise | undefined; version?: string; }>; } @@ -42,7 +46,6 @@ export const WithSource: React.FunctionComponent = ({ children sourceExists, sourceId, metricIndicesExist, - logIndicesExist, isLoading, loadSource, hasFailedLoadingSource, @@ -60,7 +63,6 @@ export const WithSource: React.FunctionComponent = ({ children isLoading, lastFailureMessage: loadSourceFailureMessage, load: loadSource, - logIndicesExist, metricIndicesExist, sourceId, update: updateSourceConfiguration, diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index 622e0c9d33845..4541eb6518788 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -14,7 +14,7 @@ import { SnapshotNodeMetric, SnapshotNodePath, } from '../../common/http_api/snapshot_api'; -import { InfraSourceConfigurationFields } from '../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../common/metrics_sources'; import { WaffleSortOption } from '../pages/metrics/inventory_view/hooks/use_waffle_options'; export interface InfraWaffleMapNode { @@ -124,7 +124,7 @@ export enum InfraWaffleMapRuleOperator { } export interface InfraWaffleMapOptions { - fields?: InfraSourceConfigurationFields | null; + fields?: MetricsSourceConfigurationProperties['fields'] | null; formatter: InfraFormatterType; formatTemplate: string; metric: SnapshotMetricInput; diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 45b17aeb1f724..bcc2eec504209 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -14,9 +14,7 @@ export const createMetricsHasData = ( ) => async () => { const [coreServices] = await getStartServices(); const { http } = coreServices; - const results = await http.get<{ hasData: boolean }>( - '/api/metrics/source/default/metrics/hasData' - ); + const results = await http.get<{ hasData: boolean }>('/api/metrics/source/default/hasData'); return results.hasData; }; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx index ea2e67abc4141..8377eadfbce1d 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx @@ -14,7 +14,7 @@ import { useHostIpToName } from './use_host_ip_to_name'; import { getFromFromLocation, getToFromLocation } from './query_params'; import { LoadingPage } from '../../components/loading_page'; import { Error } from '../error'; -import { useSource } from '../../containers/source/source'; +import { useSourceViaHttp } from '../../containers/metrics_source/use_source_via_http'; type RedirectToHostDetailType = RouteComponentProps<{ hostIp: string; @@ -26,7 +26,7 @@ export const RedirectToHostDetailViaIP = ({ }, location, }: RedirectToHostDetailType) => { - const { source } = useSource({ sourceId: 'default' }); + const { source } = useSourceViaHttp({ sourceId: 'default' }); const { error, name } = useHostIpToName( hostIp, diff --git a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx index 13eea67fb2a5a..236817ce3890f 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx @@ -19,7 +19,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface FieldsConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx index 72b5c35b958d6..e6f03e76255a2 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface IndicesConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx index 5d6ff9544e187..bc3bc22f3f1b2 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { NoIndices } from '../../../components/empty_states/no_indices'; -import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; +import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useLinkProps } from '../../../hooks/use_link_props'; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 240cb778275b1..51cc4ca098483 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -12,7 +12,7 @@ import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup, EuiButtonEmpty } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/common'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../common/metrics_sources'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -24,7 +24,7 @@ import { } from './metrics_explorer/hooks/use_metrics_explorer_options'; import { WithMetricsExplorerOptionsUrlState } from '../../containers/metrics_explorer/with_metrics_explorer_options_url_state'; import { WithSource } from '../../containers/with_source'; -import { Source } from '../../containers/source'; +import { Source } from '../../containers/metrics_source'; import { MetricsExplorerPage } from './metrics_explorer'; import { SnapshotPage } from './inventory_view'; import { MetricsSettingsPage } from './settings'; @@ -188,8 +188,8 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { }; const PageContent = (props: { - configuration: InfraSourceConfiguration; - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + configuration: MetricsSourceConfigurationProperties; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; }) => { const { createDerivedIndexPattern, configuration } = props; const { options } = useContext(MetricsExplorerOptionsContainer.Context); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 089ad9c237818..534132eb75fa1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -18,7 +18,7 @@ import { useSnapshot } from '../hooks/use_snaphot'; import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; import { DEFAULT_LEGEND, useWaffleOptionsContext } from '../hooks/use_waffle_options'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; import { InfraFormatterType } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Toolbar } from './toolbars/toolbar'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx index 7f0424cf48758..409c11cbbe897 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx @@ -43,7 +43,7 @@ import { import { PaginationControls } from './pagination'; import { AnomalySummary } from './annomaly_summary'; import { AnomalySeverityIndicator } from '../../../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; -import { useSourceContext } from '../../../../../../../containers/source'; +import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { createResultsUrl } from '../flyout_home'; import { useWaffleViewState, WaffleViewState } from '../../../../hooks/use_waffle_view_state'; type JobType = 'k8s' | 'hosts'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx index 326689e945e1d..387e739fab43f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx @@ -13,7 +13,7 @@ import { JobSetupScreen } from './job_setup_screen'; import { useInfraMLCapabilities } from '../../../../../../containers/ml/infra_ml_capabilities'; import { MetricHostsModuleProvider } from '../../../../../../containers/ml/modules/metrics_hosts/module'; import { MetricK8sModuleProvider } from '../../../../../../containers/ml/modules/metrics_k8s/module'; -import { useSourceViaHttp } from '../../../../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../../../../containers/metrics_source/use_source_via_http'; import { useActiveKibanaSpace } from '../../../../../../hooks/use_kibana_space'; export const AnomalyDetectionFlyout = () => { @@ -23,7 +23,6 @@ export const AnomalyDetectionFlyout = () => { const [screenParams, setScreenParams] = useState(null); const { source } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', }); const { space } = useActiveKibanaSpace(); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx index 894f76318bcfe..a210831eef865 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx @@ -17,7 +17,7 @@ import moment, { Moment } from 'moment'; import { EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { useSourceViaHttp } from '../../../../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../../../../containers/metrics_source/use_source_via_http'; import { useMetricK8sModuleContext } from '../../../../../../containers/ml/modules/metrics_k8s/module'; import { useMetricHostsModuleContext } from '../../../../../../containers/ml/modules/metrics_hosts/module'; import { FixedDatePicker } from '../../../../../../components/fixed_datepicker'; @@ -42,7 +42,6 @@ export const JobSetupScreen = (props: Props) => { const [filterQuery, setFilterQuery] = useState(''); const { createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', }); const indicies = h.sourceConfiguration.indices; @@ -79,7 +78,7 @@ export const JobSetupScreen = (props: Props) => { } }, [props.jobType, k.jobSummaries, h.jobSummaries]); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index d89aaefe53fd1..5ab8eb380a657 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -23,7 +23,7 @@ import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/e import { TabContent, TabProps } from '../shared'; import { useSnapshot } from '../../../../hooks/use_snaphot'; import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options'; -import { useSourceContext } from '../../../../../../../containers/source'; +import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { findInventoryFields } from '../../../../../../../../common/inventory_models'; import { convertKueryToElasticSearchQuery } from '../../../../../../../utils/kuery'; import { SnapshotMetricType } from '../../../../../../../../common/inventory_models/types'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx index 9aa2cdfd90203..010a1a9941335 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiLoadingChart } from '@elastic/eui'; import { TabContent, TabProps } from '../shared'; -import { Source } from '../../../../../../../containers/source'; +import { Source } from '../../../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../../../common/inventory_models'; import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; import { useMetadata } from '../../../../../metric_detail/hooks/use_metadata'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx index cae17c174772d..16f73734836d0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { Source } from '../../../../containers/source'; +import { Source } from '../../../../containers/metrics_source'; import { AutocompleteField } from '../../../../components/autocomplete_field'; import { WithKueryAutocompletion } from '../../../../containers/with_kuery_autocompletion'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx index 0248241d616dc..0a657b5242427 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx @@ -29,7 +29,7 @@ import { useUiSetting } from '../../../../../../../../../src/plugins/kibana_reac import { toMetricOpt } from '../../../../../../common/snapshot_metric_i18n'; import { MetricsExplorerAggregation } from '../../../../../../common/http_api'; import { colorTransformer, Color } from '../../../../../../common/color_palette'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { useTimeline } from '../../hooks/use_timeline'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; import { useWaffleTimeContext } from '../../hooks/use_waffle_time'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx index cd05341156831..1c79807f139c3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx @@ -7,7 +7,7 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexItem } from '@elastic/eui'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { SnapshotMetricInput, SnapshotGroupBy, @@ -24,7 +24,7 @@ import { WaffleOptionsState, WaffleSortOption } from '../../hooks/use_waffle_opt import { useInventoryMeta } from '../../hooks/use_inventory_meta'; export interface ToolbarProps extends Omit { - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; changeMetric: (payload: SnapshotMetricInput) => void; changeGroupBy: (payload: SnapshotGroupBy) => void; changeCustomOptions: (payload: InfraGroupByOptions[]) => void; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx index abc0089e4fc2e..7fc332ead45c7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { fieldToName } from '../../lib/field_to_display_name'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; import { WaffleInventorySwitcher } from '../waffle/waffle_inventory_switcher'; import { ToolbarProps } from './toolbar'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index 523fa5f013b5a..6dde53efae761 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -17,7 +17,7 @@ import { InfraFormatterType, } from '../../../../../lib/lib'; -jest.mock('../../../../../containers/source', () => ({ +jest.mock('../../../../../containers/metrics_source', () => ({ useSourceContext: () => ({ sourceId: 'default' }), })); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx index d0aeeca9850c4..6e334f4fbca75 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx @@ -11,7 +11,7 @@ import { first } from 'lodash'; import { getCustomMetricLabel } from '../../../../../../common/formatters/get_custom_metric_label'; import { SnapshotCustomMetricInput } from '../../../../../../common/http_api'; import { withTheme, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../common/inventory_models'; import { InventoryItemType, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts index d12bef2f3cdc0..e74abb2ecc459 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts @@ -13,7 +13,7 @@ import { useEffect, useState } from 'react'; import { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../../common/http_api'; import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; import { useHTTPRequest } from '../../../../hooks/use_http_request'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; export interface SortBy { name: string; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts index 8d7e516d50b57..cc1108cb91e6d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts @@ -17,7 +17,7 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('../../../../containers/source', () => ({ +jest.mock('../../../../containers/metrics_source', () => ({ useSourceContext: () => ({ createDerivedIndexPattern: () => 'jestbeat-*', }), diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts index 30c15410e1199..90cf96330e758 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts @@ -13,7 +13,7 @@ import { constant, identity } from 'fp-ts/lib/function'; import createContainter from 'constate'; import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; import { useUrlState } from '../../../../utils/use_url_state'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery'; import { esKuery } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 6b980d33c2559..57073fee13c18 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -17,8 +17,8 @@ import { ColumnarPage } from '../../../components/page'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; -import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; -import { Source } from '../../../containers/source'; +import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; +import { Source } from '../../../containers/metrics_source'; import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Layout } from './components/layout'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts index 1e315f95dbd7c..dbe45a387891c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts @@ -14,7 +14,6 @@ const options: InfraWaffleMapOptions = { container: 'container.id', pod: 'kubernetes.pod.uid', host: 'host.name', - message: ['@message'], timestamp: '@timestanp', tiebreaker: '@timestamp', }, diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx index 6b9912346f396..2a436eac30b2c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/e import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; -import { ViewSourceConfigurationButton } from '../../../../components/source_configuration'; +import { ViewSourceConfigurationButton } from '../../../../components/source_configuration/view_source_configuration_button'; import { useLinkProps } from '../../../../hooks/use_link_props'; interface InvalidNodeErrorProps { diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index d174707d8b6c9..13fa5cf1f0667 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -17,7 +17,7 @@ import { Header } from '../../../components/header'; import { ColumnarPage, PageContent } from '../../../components/page'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from './hooks/use_metadata'; -import { Source } from '../../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { InfraLoadingPanel } from '../../../components/loading'; import { findInventoryModel } from '../../../../common/inventory_models'; import { NavItem } from './lib/side_nav_context'; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx index ac90e488cea94..c4e1b6bf8ef16 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx @@ -7,7 +7,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; -import { Source } from '../../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { MetricsTimeProvider } from './hooks/use_metrics_time'; export const withMetricPageProviders = (Component: React.ComponentType) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx index 442382010d78c..35265f0a462cf 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/charts'; import { first, last } from 'lodash'; import moment from 'moment'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -47,7 +47,7 @@ interface Props { options: MetricsExplorerOptions; chartOptions: MetricsExplorerChartOptions; series: MetricsExplorerSeries; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; timeRange: MetricsExplorerTimeOptions; onTimeChange: (start: string, end: string) => void; } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index f5970cffa157d..8f281bda0229d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import DateMath from '@elastic/datemath'; import { Capabilities } from 'src/core/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { AlertFlyout } from '../../../../alerting/metric_threshold/components/alert_flyout'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; import { @@ -33,14 +33,14 @@ export interface Props { options: MetricsExplorerOptions; onFilter?: (query: string) => void; series: MetricsExplorerSeries; - source?: InfraSourceConfiguration; + source?: MetricsSourceConfigurationProperties; timeRange: MetricsExplorerTimeOptions; uiCapabilities?: Capabilities; chartOptions: MetricsExplorerChartOptions; } const fieldToNodeType = ( - source: InfraSourceConfiguration, + source: MetricsSourceConfigurationProperties, groupBy: string | string[] ): InventoryItemType | undefined => { const fields = Array.isArray(groupBy) ? groupBy : [groupBy]; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx index e2e64a6758a29..68faaf1f45145 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiFlexGrid, EuiFlexItem, EuiText, EuiHorizontalRule } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerResponse } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -31,7 +31,7 @@ interface Props { onFilter: (filter: string) => void; onTimeChange: (start: string, end: string) => void; data: MetricsExplorerResponse | null; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; timeRange: MetricsExplorerTimeOptions; } export const MetricsExplorerCharts = ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index d2eeada219fa4..1a549041823ec 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -8,7 +8,7 @@ import { encode } from 'rison-node'; import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; -import { InfraSourceConfiguration } from '../../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../../common/metrics_sources'; import { colorTransformer, Color } from '../../../../../../common/color_palette'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { @@ -143,7 +143,7 @@ const createTSVBIndexPattern = (alias: string) => { }; export const createTSVBLink = ( - source: InfraSourceConfiguration | undefined, + source: MetricsSourceConfigurationProperties | undefined, options: MetricsExplorerOptions, series: MetricsExplorerSeries, timeRange: MetricsExplorerTimeOptions, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index eb5a4633d4fa9..a304c81ca1298 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -7,7 +7,7 @@ import { useState, useCallback, useContext } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerMetric, MetricsExplorerAggregation, @@ -28,7 +28,7 @@ export interface MetricExplorerViewState { } export const useMetricsExplorerState = ( - source: InfraSourceConfiguration, + source: MetricsSourceConfigurationProperties, derivedIndexPattern: IIndexPattern, shouldLoadImmediately = true ) => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx index 3d09a907be12f..9a5e5fcf39ce4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx @@ -22,7 +22,7 @@ import { import { MetricsExplorerOptions, MetricsExplorerTimeOptions } from './use_metrics_explorer_options'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; import { HttpHandler } from 'kibana/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; const mockedFetch = jest.fn(); @@ -38,7 +38,7 @@ const renderUseMetricsExplorerDataHook = () => { return renderHook( (props: { options: MetricsExplorerOptions; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; derivedIndexPattern: IIndexPattern; timeRange: MetricsExplorerTimeOptions; afterKey: string | null | Record; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index b6620e963217d..6689aedcd7209 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -9,7 +9,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; import { useEffect, useState, useCallback } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerResponse, metricsExplorerResponseRT, @@ -25,7 +25,7 @@ function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOpt export function useMetricsExplorerData( options: MetricsExplorerOptions, - source: InfraSourceConfiguration | undefined, + source: MetricsSourceConfigurationProperties | undefined, derivedIndexPattern: IIndexPattern, timerange: MetricsExplorerTimeOptions, afterKey: string | null | Record, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 3eb9bbacddd2e..0d1ac47812577 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -9,7 +9,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useTrackPageview } from '../../../../../observability/public'; import { DocumentTitle } from '../../../components/document_title'; import { NoData } from '../../../components/empty_states'; @@ -19,7 +19,7 @@ import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; import { useSavedViewContext } from '../../../containers/saved_view/saved_view'; interface MetricsExplorerPageProps { - source: InfraSourceConfiguration; + source: MetricsSourceConfigurationProperties; derivedIndexPattern: IIndexPattern; } diff --git a/x-pack/plugins/infra/public/pages/metrics/settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings.tsx index c9be4abcf9e5f..c54725ab39754 100644 --- a/x-pack/plugins/infra/public/pages/metrics/settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings.tsx @@ -8,7 +8,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; +import { SourceConfigurationSettings } from './settings/source_configuration_settings'; export const MetricsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; diff --git a/x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx similarity index 98% rename from x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx index 2a8abdbc04f8e..7026f372ec7ff 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from './input_fields'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface FieldsConfigurationPanelProps { containerFieldProps: InputFieldProps; diff --git a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts similarity index 91% rename from x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts rename to x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts index b4dede79d11f2..ad26c1b13b0e1 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts @@ -11,16 +11,14 @@ import { createInputFieldProps, createInputRangeFieldProps, validateInputFieldNotEmpty, -} from './input_fields'; +} from '../../../components/source_configuration/input_fields'; interface FormState { name: string; description: string; metricAlias: string; - logAlias: string; containerField: string; hostField: string; - messageField: string[]; podField: string; tiebreakerField: string; timestampField: string; @@ -56,16 +54,6 @@ export const useIndicesConfigurationFormState = ({ }), [formState.name] ); - const logAliasFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.logAlias), - name: 'logAlias', - onChange: (logAlias) => setFormStateChanges((changes) => ({ ...changes, logAlias })), - value: formState.logAlias, - }), - [formState.logAlias] - ); const metricAliasFieldProps = useMemo( () => createInputFieldProps({ @@ -144,7 +132,6 @@ export const useIndicesConfigurationFormState = ({ const fieldProps = useMemo( () => ({ name: nameFieldProps, - logAlias: logAliasFieldProps, metricAlias: metricAliasFieldProps, containerField: containerFieldFieldProps, hostField: hostFieldFieldProps, @@ -155,7 +142,6 @@ export const useIndicesConfigurationFormState = ({ }), [ nameFieldProps, - logAliasFieldProps, metricAliasFieldProps, containerFieldFieldProps, hostFieldFieldProps, @@ -193,11 +179,9 @@ export const useIndicesConfigurationFormState = ({ const defaultFormState: FormState = { name: '', description: '', - logAlias: '', metricAlias: '', containerField: '', hostField: '', - messageField: [], podField: '', tiebreakerField: '', timestampField: '', diff --git a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx similarity index 93% rename from x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx index cff9b78777aa3..c64ab2b0e9df5 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx @@ -17,8 +17,8 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { METRICS_INDEX_PATTERN } from '../../../common/constants'; -import { InputFieldProps } from './input_fields'; +import { METRICS_INDEX_PATTERN } from '../../../../common/constants'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface IndicesConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx similarity index 96% rename from x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx index 3bd498d460391..abf25dde0ea99 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx @@ -13,7 +13,7 @@ import { EuiDescribedFormGroup } from '@elastic/eui'; import { EuiForm } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { InputRangeFieldProps } from './input_fields'; +import { InputRangeFieldProps } from '../../../components/source_configuration/input_fields'; interface MLConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx similarity index 57% rename from x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx index c80235137eea6..37da4bd1aa1bd 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx @@ -6,12 +6,12 @@ */ import { useCallback, useMemo } from 'react'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; - +import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useIndicesConfigurationFormState } from './indices_configuration_form_state'; -import { useLogColumnsConfigurationFormState } from './log_columns_configuration_form_state'; -export const useSourceConfigurationFormState = (configuration?: InfraSourceConfiguration) => { +export const useSourceConfigurationFormState = ( + configuration?: MetricsSourceConfigurationProperties +) => { const indicesConfigurationFormState = useIndicesConfigurationFormState({ initialFormState: useMemo( () => @@ -19,11 +19,9 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi ? { name: configuration.name, description: configuration.description, - logAlias: configuration.logAlias, metricAlias: configuration.metricAlias, containerField: configuration.fields.container, hostField: configuration.fields.host, - messageField: configuration.fields.message, podField: configuration.fields.pod, tiebreakerField: configuration.fields.tiebreaker, timestampField: configuration.fields.timestamp, @@ -34,43 +32,26 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi ), }); - const logColumnsConfigurationFormState = useLogColumnsConfigurationFormState({ - initialFormState: useMemo( - () => - configuration - ? { - logColumns: configuration.logColumns, - } - : undefined, - [configuration] - ), - }); - - const errors = useMemo( - () => [...indicesConfigurationFormState.errors, ...logColumnsConfigurationFormState.errors], - [indicesConfigurationFormState.errors, logColumnsConfigurationFormState.errors] - ); + const errors = useMemo(() => [...indicesConfigurationFormState.errors], [ + indicesConfigurationFormState.errors, + ]); const resetForm = useCallback(() => { indicesConfigurationFormState.resetForm(); - logColumnsConfigurationFormState.resetForm(); - }, [indicesConfigurationFormState, logColumnsConfigurationFormState]); + }, [indicesConfigurationFormState]); - const isFormDirty = useMemo( - () => indicesConfigurationFormState.isFormDirty || logColumnsConfigurationFormState.isFormDirty, - [indicesConfigurationFormState.isFormDirty, logColumnsConfigurationFormState.isFormDirty] - ); + const isFormDirty = useMemo(() => indicesConfigurationFormState.isFormDirty, [ + indicesConfigurationFormState.isFormDirty, + ]); - const isFormValid = useMemo( - () => indicesConfigurationFormState.isFormValid && logColumnsConfigurationFormState.isFormValid, - [indicesConfigurationFormState.isFormValid, logColumnsConfigurationFormState.isFormValid] - ); + const isFormValid = useMemo(() => indicesConfigurationFormState.isFormValid, [ + indicesConfigurationFormState.isFormValid, + ]); const formState = useMemo( () => ({ name: indicesConfigurationFormState.formState.name, description: indicesConfigurationFormState.formState.description, - logAlias: indicesConfigurationFormState.formState.logAlias, metricAlias: indicesConfigurationFormState.formState.metricAlias, fields: { container: indicesConfigurationFormState.formState.containerField, @@ -79,17 +60,15 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi tiebreaker: indicesConfigurationFormState.formState.tiebreakerField, timestamp: indicesConfigurationFormState.formState.timestampField, }, - logColumns: logColumnsConfigurationFormState.formState.logColumns, anomalyThreshold: indicesConfigurationFormState.formState.anomalyThreshold, }), - [indicesConfigurationFormState.formState, logColumnsConfigurationFormState.formState] + [indicesConfigurationFormState.formState] ); const formStateChanges = useMemo( () => ({ name: indicesConfigurationFormState.formStateChanges.name, description: indicesConfigurationFormState.formStateChanges.description, - logAlias: indicesConfigurationFormState.formStateChanges.logAlias, metricAlias: indicesConfigurationFormState.formStateChanges.metricAlias, fields: { container: indicesConfigurationFormState.formStateChanges.containerField, @@ -98,25 +77,18 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi tiebreaker: indicesConfigurationFormState.formStateChanges.tiebreakerField, timestamp: indicesConfigurationFormState.formStateChanges.timestampField, }, - logColumns: logColumnsConfigurationFormState.formStateChanges.logColumns, anomalyThreshold: indicesConfigurationFormState.formStateChanges.anomalyThreshold, }), - [ - indicesConfigurationFormState.formStateChanges, - logColumnsConfigurationFormState.formStateChanges, - ] + [indicesConfigurationFormState.formStateChanges] ); return { - addLogColumn: logColumnsConfigurationFormState.addLogColumn, - moveLogColumn: logColumnsConfigurationFormState.moveLogColumn, errors, formState, formStateChanges, isFormDirty, isFormValid, indicesConfigurationProps: indicesConfigurationFormState.fieldProps, - logColumnConfigurationProps: logColumnsConfigurationFormState.logColumnConfigurationProps, resetForm, }; }; diff --git a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx similarity index 94% rename from x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx index e63f43470497d..71fa4e7600503 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx @@ -19,15 +19,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useContext, useMemo } from 'react'; -import { Source } from '../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { FieldsConfigurationPanel } from './fields_configuration_panel'; import { IndicesConfigurationPanel } from './indices_configuration_panel'; -import { NameConfigurationPanel } from './name_configuration_panel'; +import { NameConfigurationPanel } from '../../../components/source_configuration/name_configuration_panel'; import { useSourceConfigurationFormState } from './source_configuration_form_state'; -import { SourceLoadingPage } from '../source_loading_page'; -import { Prompt } from '../../utils/navigation_warning_prompt'; +import { SourceLoadingPage } from '../../../components/source_loading_page'; +import { Prompt } from '../../../utils/navigation_warning_prompt'; import { MLConfigurationPanel } from './ml_configuration_panel'; -import { useInfraMLCapabilitiesContext } from '../../containers/ml/infra_ml_capabilities'; +import { useInfraMLCapabilitiesContext } from '../../../containers/ml/infra_ml_capabilities'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 4d70676d25e40..068abd0e0f20f 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -19,8 +19,8 @@ import type { } from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import type { - ObservabilityPluginSetup, - ObservabilityPluginStart, + ObservabilityPublicSetup, + ObservabilityPublicStart, } from '../../observability/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { MlPluginStart, MlPluginSetup } from '../../ml/public'; @@ -33,7 +33,7 @@ export type InfraClientStartExports = void; export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; home?: HomePublicPluginSetup; - observability: ObservabilityPluginSetup; + observability: ObservabilityPublicSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; ml: MlPluginSetup; @@ -43,7 +43,7 @@ export interface InfraClientSetupDeps { export interface InfraClientStartDeps { data: DataPublicPluginStart; dataEnhanced: DataEnhancedStart; - observability: ObservabilityPluginStart; + observability: ObservabilityPublicStart; spaces: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection: UsageCollectionStart; diff --git a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx index 124b6b8f13bf9..33fbbd03d790a 100644 --- a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx +++ b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { act as reactAct } from 'react-dom/test-utils'; diff --git a/x-pack/plugins/infra/public/utils/source_configuration.ts b/x-pack/plugins/infra/public/utils/source_configuration.ts index b7b45d1927711..a3e1741c7590b 100644 --- a/x-pack/plugins/infra/public/utils/source_configuration.ts +++ b/x-pack/plugins/infra/public/utils/source_configuration.ts @@ -6,14 +6,14 @@ */ import { - InfraSavedSourceConfigurationColumn, - InfraSavedSourceConfigurationFields, + InfraSourceConfigurationColumn, + InfraSourceConfigurationFieldColumn, InfraSourceConfigurationMessageColumn, InfraSourceConfigurationTimestampColumn, -} from '../../common/http_api/source_api'; +} from '../../common/source_configuration/source_configuration'; -export type LogColumnConfiguration = InfraSavedSourceConfigurationColumn; -export type FieldLogColumnConfiguration = InfraSavedSourceConfigurationFields; +export type LogColumnConfiguration = InfraSourceConfigurationColumn; +export type FieldLogColumnConfiguration = InfraSourceConfigurationFieldColumn; export type MessageLogColumnConfiguration = InfraSourceConfigurationMessageColumn; export type TimestampLogColumnConfiguration = InfraSourceConfigurationTimestampColumn; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 69595c90c7911..f42207e0ad142 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -32,7 +32,7 @@ import { } from './routes/log_entries'; import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; -import { initSourceRoute } from './routes/source'; +import { initMetricsSourceConfigurationRoutes } from './routes/metrics_sources'; import { initOverviewRoute } from './routes/overview'; import { initAlertPreviewRoute } from './routes/alerting'; import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts'; @@ -50,7 +50,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initGetHostsAnomaliesRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); - initSourceRoute(libs); + initMetricsSourceConfigurationRoutes(libs); initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initGetLogEntryExamplesRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index e390d6525cd60..921634361f4a2 100644 --- a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -34,7 +34,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { options: InfraMetricsRequestOptions, rawRequest: KibanaRequest ): Promise { - const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; + const indexPattern = `${options.sourceConfiguration.metricAlias}`; const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); const nodeField = fields.id; @@ -112,7 +112,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { ); } - const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; + const indexPattern = `${options.sourceConfiguration.metricAlias}`; const timerange = { min: options.timerange.from, max: options.timerange.to, @@ -132,7 +132,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { const calculatedInterval = await calculateMetricInterval( client, { - indexPattern: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`, + indexPattern: `${options.sourceConfiguration.metricAlias}`, timestampField: options.sourceConfiguration.fields.timestamp, timerange: options.timerange, }, 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 439764f80186e..5244b8a81e75f 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 @@ -23,6 +23,7 @@ import { InfraTimerangeInput, SnapshotRequest } from '../../../../common/http_ap import { InfraSource } from '../../sources'; import { UNGROUPED_FACTORY_KEY } from '../common/utils'; import { getNodes } from '../../../routes/snapshot/lib/get_nodes'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean[]; @@ -36,6 +37,7 @@ export const evaluateCondition = async ( condition: InventoryMetricConditions, nodeType: InventoryItemType, source: InfraSource, + logQueryFields: LogQueryFields, esClient: ElasticsearchClient, filterQuery?: string, lookbackSize?: number @@ -58,6 +60,7 @@ export const evaluateCondition = async ( metric, timerange, source, + logQueryFields, filterQuery, customMetric ); @@ -101,6 +104,7 @@ const getData = async ( metric: SnapshotMetricType, timerange: InfraTimerangeInput, source: InfraSource, + logQueryFields: LogQueryFields, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { @@ -124,7 +128,7 @@ const getData = async ( includeTimeseries: Boolean(timerange.lookbackSize), }; try { - const { nodes } = await getNodes(client, snapshotRequest, source); + const { nodes } = await getNodes(client, snapshotRequest, source, logQueryFields); 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 632ba9cd6f282..d775a503d1d32 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 @@ -68,12 +68,18 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = sourceId || 'default' ); + const logQueryFields = await libs.getLogQueryFields( + sourceId || 'default', + services.savedObjectsClient + ); + const results = await Promise.all( criteria.map((c) => evaluateCondition( c, nodeType, source, + logQueryFields, services.scopedClusterClient.asCurrentUser, 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 472f9d408694c..f254f1e68ae46 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 @@ -14,10 +14,11 @@ import { isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; import { ElasticsearchClient } from '../../../../../../../src/core/server'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../common/source_configuration/source_configuration'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InventoryItemType } from '../../../../common/inventory_models/types'; import { evaluateCondition } from './evaluate_condition'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; interface InventoryMetricThresholdParams { criteria: InventoryMetricConditions[]; @@ -30,6 +31,7 @@ interface PreviewInventoryMetricThresholdAlertParams { esClient: ElasticsearchClient; params: InventoryMetricThresholdParams; source: InfraSource; + logQueryFields: LogQueryFields; lookback: Unit; alertInterval: string; alertThrottle: string; @@ -43,6 +45,7 @@ export const previewInventoryMetricThresholdAlert: ( esClient, params, source, + logQueryFields, lookback, alertInterval, alertThrottle, @@ -68,7 +71,7 @@ export const previewInventoryMetricThresholdAlert: ( try { const results = await Promise.all( criteria.map((c) => - evaluateCondition(c, nodeType, source, esClient, filterQuery, lookbackSize) + evaluateCondition(c, nodeType, source, logQueryFields, esClient, filterQuery, lookbackSize) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index f6214edc5d0ab..87150aa134837 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -11,7 +11,7 @@ import { isTooManyBucketsPreviewException, TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, } from '../../../../../common/alerting/metrics'; -import { InfraSource } from '../../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../../common/source_configuration/source_configuration'; import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index 064804b661b74..a4c207f4006d5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -12,7 +12,7 @@ import { isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; import { ElasticsearchClient } from '../../../../../../../src/core/server'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../common/source_configuration/source_configuration'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { PreviewResult } from '../common/types'; import { MetricExpressionParams } from './types'; diff --git a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts index b653351a34760..d5ffa56987666 100644 --- a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts @@ -18,21 +18,16 @@ export class InfraFieldsDomain { public async getFields( requestContext: InfraPluginRequestHandlerContext, sourceId: string, - indexType: 'LOGS' | 'METRICS' | 'ANY' + indexType: 'LOGS' | 'METRICS' ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId ); - const includeMetricIndices = ['ANY', 'METRICS'].includes(indexType); - const includeLogIndices = ['ANY', 'LOGS'].includes(indexType); const fields = await this.adapter.getIndexFields( requestContext, - [ - ...(includeMetricIndices ? [configuration.metricAlias] : []), - ...(includeLogIndices ? [configuration.logAlias] : []), - ].join(',') + indexType === 'LOGS' ? configuration.logAlias : configuration.metricAlias ); return fields; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index e3c42c4dceede..278ae0e086cfc 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -17,7 +17,7 @@ import { LogColumn, LogEntryCursor, LogEntry } from '../../../../common/log_entr import { InfraSourceConfiguration, InfraSources, - SavedSourceConfigurationFieldColumnRuntimeType, + SourceConfigurationFieldColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from '../../../services/log_entries/message/builtin_rules'; import { @@ -349,7 +349,7 @@ const getRequiredFields = ( ): string[] => { const fieldsFromCustomColumns = configuration.logColumns.reduce( (accumulatedFields, logColumn) => { - if (SavedSourceConfigurationFieldColumnRuntimeType.is(logColumn)) { + if (SourceConfigurationFieldColumnRuntimeType.is(logColumn)) { return [...accumulatedFields, logColumn.fieldColumn.field]; } return accumulatedFields; diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 65bb5f878b275..08e42279e4939 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { InfraSourceConfiguration } from '../../common/http_api/source_api'; +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'; @@ -13,6 +13,7 @@ import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; import { InfraConfig } from '../plugin'; import { KibanaFramework } from './adapters/framework/kibana_framework_adapter'; +import { GetLogQueryFields } from '../services/log_queries/get_log_query_fields'; export interface InfraDomainLibs { fields: InfraFieldsDomain; @@ -25,6 +26,7 @@ export interface InfraBackendLibs extends InfraDomainLibs { framework: KibanaFramework; sources: InfraSources; sourceStatus: InfraSourceStatus; + getLogQueryFields: GetLogQueryFields; } export interface InfraConfiguration { diff --git a/x-pack/plugins/infra/server/lib/metrics/index.ts b/x-pack/plugins/infra/server/lib/metrics/index.ts index cb89c5a6b1bd3..e436ad2ba0b05 100644 --- a/x-pack/plugins/infra/server/lib/metrics/index.ts +++ b/x-pack/plugins/infra/server/lib/metrics/index.ts @@ -120,5 +120,5 @@ export const query = async ( ThrowReporter.report(HistogramResponseRT.decode(response.aggregations)); } - throw new Error('Elasticsearch responsed with an unrecoginzed format.'); + throw new Error('Elasticsearch responded with an unrecognized format.'); }; diff --git a/x-pack/plugins/infra/server/lib/sources/defaults.ts b/x-pack/plugins/infra/server/lib/sources/defaults.ts index 1b924619a905c..ff6d6a4f5514b 100644 --- a/x-pack/plugins/infra/server/lib/sources/defaults.ts +++ b/x-pack/plugins/infra/server/lib/sources/defaults.ts @@ -10,7 +10,7 @@ import { LOGS_INDEX_PATTERN, TIMESTAMP_FIELD, } from '../../../common/constants'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; +import { InfraSourceConfiguration } from '../../../common/source_configuration/source_configuration'; export const defaultSourceConfiguration: InfraSourceConfiguration = { name: 'Default', diff --git a/x-pack/plugins/infra/server/lib/sources/index.ts b/x-pack/plugins/infra/server/lib/sources/index.ts index 57852f7f3e4e6..27ad665be31a9 100644 --- a/x-pack/plugins/infra/server/lib/sources/index.ts +++ b/x-pack/plugins/infra/server/lib/sources/index.ts @@ -8,4 +8,4 @@ export * from './defaults'; export { infraSourceConfigurationSavedObjectType } from './saved_object_type'; export * from './sources'; -export * from '../../../common/http_api/source_api'; +export * from '../../../common/source_configuration/source_configuration'; diff --git a/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts b/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts index dbfe0f81c187a..e71994fe11517 100644 --- a/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts +++ b/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts @@ -6,7 +6,7 @@ */ import { SavedObjectMigrationFn } from 'src/core/server'; -import { InfraSourceConfiguration } from '../../../../common/http_api/source_api'; +import { InfraSourceConfiguration } from '../../../../common/source_configuration/source_configuration'; export const addNewIndexingStrategyIndexNames: SavedObjectMigrationFn< InfraSourceConfiguration, diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index fe005b04978da..7abbed0a9fbdd 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -23,7 +23,7 @@ import { SourceConfigurationSavedObjectRuntimeType, StaticSourceConfigurationRuntimeType, InfraSource, -} from '../../../common/http_api/source_api'; +} from '../../../common/source_configuration/source_configuration'; import { InfraConfig } from '../../../server'; interface Libs { diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index c80e012844c1e..50fec38b9f2df 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -9,7 +9,7 @@ import { Server } from '@hapi/hapi'; import { schema, TypeOf } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { CoreSetup, PluginInitializerContext, Plugin } from 'src/core/server'; -import { InfraStaticSourceConfiguration } from '../common/http_api/source_api'; +import { InfraStaticSourceConfiguration } from '../common/source_configuration/source_configuration'; import { inventoryViewSavedObjectType } from '../common/saved_objects/inventory_view'; import { metricsExplorerViewSavedObjectType } from '../common/saved_objects/metrics_explorer_view'; import { LOGS_FEATURE, METRICS_FEATURE } from './features'; @@ -30,6 +30,7 @@ import { InfraSourceStatus } from './lib/source_status'; import { LogEntriesService } from './services/log_entries'; import { InfraPluginRequestHandlerContext } from './types'; import { UsageCollector } from './usage/usage_collector'; +import { createGetLogQueryFields } from './services/log_queries/get_log_query_fields'; export const config = { schema: schema.object({ @@ -123,6 +124,7 @@ export class InfraServerPlugin implements Plugin { sources, sourceStatus, ...domainLibs, + getLogQueryFields: createGetLogQueryFields(sources), }; plugins.features.registerKibanaFeature(METRICS_FEATURE); diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 6622df1a8333a..4d980834d3a70 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -25,7 +25,11 @@ import { previewMetricAnomalyAlert } from '../../lib/alerting/metric_anomaly/pre import { InfraBackendLibs } from '../../lib/infra_types'; import { assertHasInfraMlPlugins } from '../../utils/request_context'; -export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => { +export const initAlertPreviewRoute = ({ + framework, + sources, + getLogQueryFields, +}: InfraBackendLibs) => { framework.registerRoute( { method: 'post', @@ -77,6 +81,10 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { + const logQueryFields = await getLogQueryFields( + sourceId || 'default', + requestContext.core.savedObjects.client + ); const { nodeType, criteria, @@ -87,6 +95,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) params: { criteria, filterQuery, nodeType }, lookback, source, + logQueryFields, alertInterval, alertThrottle, alertNotifyWhen, diff --git a/x-pack/plugins/infra/server/routes/source/index.ts b/x-pack/plugins/infra/server/routes/metrics_sources/index.ts similarity index 69% rename from x-pack/plugins/infra/server/routes/source/index.ts rename to x-pack/plugins/infra/server/routes/metrics_sources/index.ts index 5ab3275f9ea9e..0123e4678697c 100644 --- a/x-pack/plugins/infra/server/routes/source/index.ts +++ b/x-pack/plugins/infra/server/routes/metrics_sources/index.ts @@ -8,63 +8,49 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { createValidationFunction } from '../../../common/runtime_types'; -import { - InfraSourceStatus, - SavedSourceConfigurationRuntimeType, - SourceResponseRuntimeType, -} from '../../../common/http_api/source_api'; import { InfraBackendLibs } from '../../lib/infra_types'; import { hasData } from '../../lib/sources/has_data'; import { createSearchClient } from '../../lib/create_search_client'; import { AnomalyThresholdRangeError } from '../../lib/sources/errors'; +import { + partialMetricsSourceConfigurationPropertiesRT, + metricsSourceConfigurationResponseRT, + MetricsSourceStatus, +} from '../../../common/metrics_sources'; -const typeToInfraIndexType = (value: string | undefined) => { - switch (value) { - case 'metrics': - return 'METRICS'; - case 'logs': - return 'LOGS'; - default: - return 'ANY'; - } -}; - -export const initSourceRoute = (libs: InfraBackendLibs) => { +export const initMetricsSourceConfigurationRoutes = (libs: InfraBackendLibs) => { const { framework } = libs; framework.registerRoute( { method: 'get', - path: '/api/metrics/source/{sourceId}/{type?}', + path: '/api/metrics/source/{sourceId}', validate: { params: schema.object({ sourceId: schema.string(), - type: schema.string(), }), }, }, async (requestContext, request, response) => { - const { type, sourceId } = request.params; + const { sourceId } = request.params; - const [source, logIndexStatus, metricIndicesExist, indexFields] = await Promise.all([ + const [source, metricIndicesExist, indexFields] = await Promise.all([ libs.sources.getSourceConfiguration(requestContext.core.savedObjects.client, sourceId), - libs.sourceStatus.getLogIndexStatus(requestContext, sourceId), libs.sourceStatus.hasMetricIndices(requestContext, sourceId), - libs.fields.getFields(requestContext, sourceId, typeToInfraIndexType(type)), + libs.fields.getFields(requestContext, sourceId, 'METRICS'), ]); if (!source) { return response.notFound(); } - const status: InfraSourceStatus = { - logIndicesExist: logIndexStatus !== 'missing', + const status: MetricsSourceStatus = { metricIndicesExist, indexFields, }; return response.ok({ - body: SourceResponseRuntimeType.encode({ source: { ...source, status } }), + body: metricsSourceConfigurationResponseRT.encode({ source: { ...source, status } }), }); } ); @@ -77,7 +63,7 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { params: schema.object({ sourceId: schema.string(), }), - body: createValidationFunction(SavedSourceConfigurationRuntimeType), + body: createValidationFunction(partialMetricsSourceConfigurationPropertiesRT), }, }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { @@ -110,20 +96,18 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { patchedSourceConfigurationProperties )); - const [logIndexStatus, metricIndicesExist, indexFields] = await Promise.all([ - libs.sourceStatus.getLogIndexStatus(requestContext, sourceId), + const [metricIndicesExist, indexFields] = await Promise.all([ libs.sourceStatus.hasMetricIndices(requestContext, sourceId), - libs.fields.getFields(requestContext, sourceId, typeToInfraIndexType('metrics')), + libs.fields.getFields(requestContext, sourceId, 'METRICS'), ]); - const status: InfraSourceStatus = { - logIndicesExist: logIndexStatus !== 'missing', + const status: MetricsSourceStatus = { metricIndicesExist, indexFields, }; return response.ok({ - body: SourceResponseRuntimeType.encode({ + body: metricsSourceConfigurationResponseRT.encode({ source: { ...patchedSourceConfiguration, status }, }), }); @@ -154,25 +138,23 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { framework.registerRoute( { method: 'get', - path: '/api/metrics/source/{sourceId}/{type}/hasData', + path: '/api/metrics/source/{sourceId}/hasData', validate: { params: schema.object({ sourceId: schema.string(), - type: schema.string(), }), }, }, async (requestContext, request, response) => { - const { type, sourceId } = request.params; + const { sourceId } = request.params; const client = createSearchClient(requestContext, framework); const source = await libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId ); - const indexPattern = - type === 'metrics' ? source.configuration.metricAlias : source.configuration.logAlias; - const results = await hasData(indexPattern, client); + + const results = await hasData(source.configuration.metricAlias, client); return response.ok({ body: { hasData: results }, diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index aaf23085d0d60..cbadd26ccd4bf 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -41,9 +41,15 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { snapshotRequest.sourceId ); + const logQueryFields = await libs.getLogQueryFields( + snapshotRequest.sourceId, + requestContext.core.savedObjects.client + ); + UsageCollector.countNode(snapshotRequest.nodeType); const client = createSearchClient(requestContext, framework); - const snapshotResponse = await getNodes(client, snapshotRequest, source); + + const snapshotResponse = await getNodes(client, snapshotRequest, source, logQueryFields); return response.ok({ body: SnapshotNodeResponseRT.encode(snapshotResponse), diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts deleted file mode 100644 index 85c1ece1ca042..0000000000000 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts +++ /dev/null @@ -1,23 +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 { SnapshotRequest } from '../../../../common/http_api'; -import { InfraSource } from '../../../lib/sources'; - -export const calculateIndexPatterBasedOnMetrics = ( - options: SnapshotRequest, - source: InfraSource -) => { - const { metrics } = options; - if (metrics.every((m) => m.type === 'logRate')) { - return source.configuration.logAlias; - } - if (metrics.some((m) => m.type === 'logRate')) { - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; - } - return source.configuration.metricAlias; -}; 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 9dec21d3ab1c7..ff3cf048b99de 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 @@ -12,16 +12,24 @@ import { transformRequestToMetricsAPIRequest } from './transform_request_to_metr import { queryAllData } from './query_all_data'; import { transformMetricsApiResponseToSnapshotResponse } from './trasform_metrics_ui_response'; import { copyMissingMetrics } from './copy_missing_metrics'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; -export const getNodes = async ( +export interface SourceOverrides { + indexPattern: string; + timestamp: string; +} + +const transformAndQueryData = async ( client: ESSearchClient, snapshotRequest: SnapshotRequest, - source: InfraSource + source: InfraSource, + sourceOverrides?: SourceOverrides ) => { const metricsApiRequest = await transformRequestToMetricsAPIRequest( client, source, - snapshotRequest + snapshotRequest, + sourceOverrides ); const metricsApiResponse = await queryAllData(client, metricsApiRequest); const snapshotResponse = transformMetricsApiResponseToSnapshotResponse( @@ -32,3 +40,59 @@ export const getNodes = async ( ); return copyMissingMetrics(snapshotResponse); }; + +export const getNodes = async ( + client: ESSearchClient, + snapshotRequest: SnapshotRequest, + source: InfraSource, + logQueryFields: LogQueryFields +) => { + 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); + } 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( + client, + { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, + source + ); + const logRateNodes = await transformAndQueryData( + client, + { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, + source, + 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( + (_logRateNode) => node.name === _logRateNode.name + ); + if (logRateNode) { + // Remove this from the "leftovers" + logRateNodes.nodes.filter((_node) => _node.name !== logRateNode.name); + } + return logRateNode + ? { + ...node, + metrics: [...node.metrics, ...logRateNode.metrics], + } + : node; + }); + nodes = { + ...nodesWithoutLogsMetrics, + nodes: [...mergedNodes, ...logRateNodes.nodes], + }; + } + } else { + nodes = await transformAndQueryData(client, snapshotRequest, source); + } + + return nodes; +}; 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 8804121fc4167..128137efa272e 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 @@ -12,13 +12,14 @@ import { InfraSource } from '../../../lib/sources'; import { createTimeRangeWithInterval } from './create_timerange_with_interval'; import { parseFilterQuery } from '../../../utils/serialized_query'; import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapshot_metrics_to_metrics_api_metrics'; -import { calculateIndexPatterBasedOnMetrics } from './calculate_index_pattern_based_on_metrics'; import { META_KEY } from './constants'; +import { SourceOverrides } from './get_nodes'; export const transformRequestToMetricsAPIRequest = async ( client: ESSearchClient, source: InfraSource, - snapshotRequest: SnapshotRequest + snapshotRequest: SnapshotRequest, + sourceOverrides?: SourceOverrides ): Promise => { const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, { ...snapshotRequest, @@ -27,9 +28,9 @@ export const transformRequestToMetricsAPIRequest = async ( }); const metricsApiRequest: MetricsAPIRequest = { - indexPattern: calculateIndexPatterBasedOnMetrics(snapshotRequest, source), + indexPattern: sourceOverrides?.indexPattern ?? source.configuration.metricAlias, timerange: { - field: source.configuration.fields.timestamp, + field: sourceOverrides?.timestamp ?? source.configuration.fields.timestamp, from: timeRangeWithIntervalApplied.from, to: timeRangeWithIntervalApplied.to, interval: timeRangeWithIntervalApplied.interval, @@ -74,7 +75,7 @@ export const transformRequestToMetricsAPIRequest = async ( top_hits: { size: 1, _source: [inventoryFields.name], - sort: [{ [source.configuration.fields.timestamp]: 'desc' }], + sort: [{ [sourceOverrides?.timestamp ?? source.configuration.fields.timestamp]: 'desc' }], }, }, }, diff --git a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts index b06752ee0a80d..c16d65a75b3e0 100644 --- a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts +++ b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts @@ -45,6 +45,37 @@ export const getGenericRules = (genericMessageFields: string[]) => [ ]; const createGenericRulesForField = (fieldName: string) => [ + { + when: { + exists: ['event.dataset', 'log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'event.dataset', + }, + { + constant: '][', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['event.dataset', 'log.level', fieldName], @@ -70,6 +101,31 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: ['log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['log.level', fieldName], @@ -89,6 +145,22 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: [fieldName, 'error.stack_trace.text'], + }, + format: [ + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: [fieldName], diff --git a/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts b/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts new file mode 100644 index 0000000000000..9497a8b442768 --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { InfraSources } from '../../lib/sources'; + +// NOTE: TEMPORARY: This will become a subset of the new resolved KIP compatible log source configuration. +export interface LogQueryFields { + indexPattern: string; + timestamp: string; +} + +// NOTE: TEMPORARY: This will become a subset of the new resolved KIP compatible log source configuration. +export const createGetLogQueryFields = (sources: InfraSources) => { + return async ( + sourceId: string, + savedObjectsClient: SavedObjectsClientContract + ): Promise => { + const source = await sources.getSourceConfiguration(savedObjectsClient, sourceId); + + return { + indexPattern: source.configuration.logAlias, + timestamp: source.configuration.fields.timestamp, + }; + }; +}; + +export type GetLogQueryFields = ReturnType; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx new file mode 100644 index 0000000000000..c6449dbd7a93e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 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 { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the Bytes processor when saved +const defaultBytesParameters = { + ignore_failure: undefined, + description: undefined, +}; + +const BYTES_TYPE = 'bytes'; + +describe('Processor: Bytes', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + testBed.component.update(); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with default parameter values', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + field: 'field_1', + ...defaultBytesParameters, + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { addProcessor, addProcessorType, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('targetField.input', 'target_field'); + + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + description: undefined, + field: 'field_1', + ignore_failure: undefined, + target_field: 'target_field', + ignore_missing: true, + tag: undefined, + if: undefined, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index c08627de636d7..8340cf45b1f1b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -90,9 +90,9 @@ const createActions = (testBed: TestBed) => { component.update(); }, - async addProcessorType({ type, label }: { type: string; label: string }) { + async addProcessorType(type: string) { await act(async () => { - find('processorTypeSelector.input').simulate('change', [{ value: type, label }]); + find('processorTypeSelector.input').simulate('change', [{ value: type }]); }); component.update(); }, @@ -127,12 +127,19 @@ export const setupEnvironment = () => { }; }; +export const getProcessorValue = (onUpdate: jest.Mock, type: string) => { + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + return processors; +}; + type TestSubject = | 'addProcessorForm.submitButton' | 'addProcessorButton' | 'addProcessorForm.submitButton' | 'processorTypeSelector.input' | 'fieldNameField.input' + | 'ignoreMissingSwitch.input' | 'targetField.input' | 'keepOriginalField.input' | 'removeIfSuccessfulField.input'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx new file mode 100644 index 0000000000000..de0061dcb0407 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult } from './processor.helpers'; + +describe('Processor: Bytes', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + testBed.component.update(); + }); + + test('Prevents form submission if processor type not selected', async () => { + const { + actions: { addProcessor, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx index 41078b7e96df9..573adad3247f5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx @@ -6,7 +6,7 @@ */ import { act } from 'react-dom/test-utils'; -import { setup, SetupResult } from './processor.helpers'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; // Default parameter values automatically added to the URI parts processor when saved const defaultUriPartsParameters = { @@ -16,6 +16,8 @@ const defaultUriPartsParameters = { description: undefined, }; +const URI_PARTS_TYPE = 'uri_parts'; + describe('Processor: URI parts', () => { let onUpdate: jest.Mock; let testBed: SetupResult; @@ -51,14 +53,9 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); - // Click submit button without entering any fields - await saveNewProcessor(); - - // Expect form error as a processor type is required - expect(form.getErrorsMessages()).toEqual(['A type is required.']); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Click submit button with only the type defined await saveNewProcessor(); @@ -76,14 +73,13 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); // Save the field await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ field: 'field_1', ...defaultUriPartsParameters, @@ -99,7 +95,7 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); @@ -111,8 +107,7 @@ describe('Processor: URI parts', () => { // Save the field with new changes await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ description: undefined, field: 'field_1', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx index 82e086102b488..744e9798c4fb0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx @@ -50,5 +50,6 @@ export const IgnoreMissingField: FunctionComponent = (props) => ( config={{ ...fieldsConfig.ignore_missing, ...props }} component={ToggleField} path="fields.ignore_missing" + data-test-subj="ignoreMissingSwitch" /> ); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 38bcf8a377bf2..20bf349f6b13a 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -72,7 +72,7 @@ const { TopNavMenu } = navigationStartMock.ui; function createMockFrame(): jest.Mocked { return { - mount: jest.fn((el, props) => {}), + mount: jest.fn(async (el, props) => {}), unmount: jest.fn(() => {}), }; } diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index 7aa838021f2a8..7de406aee2534 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,24 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DragDrop defined dropType is reflected in the className 1`] = ` - + +
`; -exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` - + + `; exports[`DragDrop renders if nothing is being dragged 1`] = ` diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 961f7ee0ec400..57ebe79af2219 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -3,7 +3,9 @@ .lnsDragDrop { user-select: none; - transition: background-color $euiAnimSpeedFast ease-in-out, border-color $euiAnimSpeedFast ease-in-out; + transition: $euiAnimSpeedFast ease-in-out; + transition-property: background-color, border-color, opacity; + z-index: $euiZLevel1; } .lnsDragDrop_ghost { @@ -16,7 +18,7 @@ left: 0; opacity: .9; transform: translate(-12px, 8px); - z-index: $euiZLevel2; + z-index: $euiZLevel3; pointer-events: none; box-shadow: 0 0 0 $euiFocusRingSize $euiFocusRingColor; } @@ -56,6 +58,7 @@ // Drop area while hovering with item .lnsDragDrop-isActiveDropTarget { + z-index: $euiZLevel3; @include lnsDroppableActiveHover; } @@ -81,6 +84,16 @@ } } +.lnsDragDrop__container { + position: relative; + width: 100%; + height: 100%; + + &.lnsDragDrop__container-active { + z-index: $euiZLevel3; + } +} + .lnsDragDrop__reorderableDrop { position: absolute; width: 100%; @@ -92,6 +105,14 @@ transform: translateY(0); transition: transform $euiAnimSpeedFast ease-in-out; pointer-events: none; + + .lnsDragDrop-isDropTarget { + @include lnsDraggable; + } + + .lnsDragDrop-isActiveDropTarget { + z-index: $euiZLevel3; + } } .lnsDragDrop-translatableDrag { @@ -118,10 +139,6 @@ // Draggable item when it is moving .lnsDragDrop-isHidden { opacity: 0; -} - -.lnsDragDrop-isHidden-noFocus { - opacity: 0; .lnsDragDrop__keyboardHandler { &:focus, &:focus-within { @@ -129,3 +146,60 @@ } } } + +.lnsDragDrop__extraDrops { + opacity: 0; + visibility: hidden; + position: absolute; + z-index: $euiZLevel2; + right: calc(100% + #{$euiSizeS}); + top: 0; + transition: opacity $euiAnimSpeedFast ease-in-out; + width:100%; +} + +.lnsDragDrop__extraDrops-visible { + opacity: 1; + visibility: visible; +} + +.lnsDragDrop__diamondPath { + position: absolute; + width: 30%; + top: 0; + left: -$euiSize; + z-index: $euiZLevel0; +} + +.lnsDragDrop__extraDropWrapper { + position: relative; + width: 100%; + height: 100%; + background: $euiColorLightestShade; + padding: $euiSizeXS; + border-radius: 0; + &:first-child, &:first-child .lnsDragDrop__extraDrop { + border-top-left-radius: $euiSizeXS; + border-top-right-radius: $euiSizeXS; + } + &:last-child, &:last-child .lnsDragDrop__extraDrop { + border-bottom-left-radius: $euiSizeXS; + border-bottom-right-radius: $euiSizeXS; + } +} + +// collapse borders +.lnsDragDrop__extraDropWrapper + .lnsDragDrop__extraDropWrapper { + margin-top: -1px; +} + +.lnsDragDrop__extraDrop { + position: relative; + height: $euiSizeXS * 10; + min-width: $euiSize * 7; + color: $euiColorSuccessText; + padding: $euiSizeXS; + &.lnsDragDrop-incompatibleExtraDrop { + color: $euiColorWarningText; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index dd1e351b824fe..e582c4318afc3 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, mount } from 'enzyme'; +import { render, mount, ReactWrapper } from 'enzyme'; import { DragDrop } from './drag_drop'; import { ChildDragDropProvider, @@ -39,7 +39,11 @@ describe('DragDrop', () => { registerDropTarget: jest.fn(), }; - const value = { id: '1', humanData: { label: 'hello' } }; + const value = { + id: '1', + humanData: { label: 'hello', groupLabel: 'X', position: 1, canSwap: true, canDuplicate: true }, + }; + test('renders if nothing is being dragged', () => { const component = render( @@ -53,17 +57,17 @@ describe('DragDrop', () => { test('dragover calls preventDefault if dropType is defined', () => { const preventDefault = jest.fn(); const component = mount( - + ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragover', { preventDefault }); expect(preventDefault).toBeCalled(); }); - test('dragover does not call preventDefault if dropType is undefined', () => { + test('dragover does not call preventDefault if dropTypes is undefined', () => { const preventDefault = jest.fn(); const component = mount( @@ -71,7 +75,7 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragover', { preventDefault }); expect(preventDefault).not.toBeCalled(); }); @@ -85,7 +89,7 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('mousedown'); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('mousedown'); expect(global.getSelection).toBeCalled(); expect(removeAllRanges).toBeCalled(); }); @@ -107,9 +111,11 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragstart', { dataTransfer }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); expect(setDragging).toBeCalledWith({ ...value }); @@ -128,15 +134,15 @@ describe('DragDrop', () => { dragging={{ id: '2', humanData: { label: 'Label1' } }} setDragging={setDragging} > - + ); - component - .find('[data-test-subj="lnsDragDrop"]') - .simulate('drop', { preventDefault, stopPropagation }); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(0); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); @@ -144,7 +150,7 @@ describe('DragDrop', () => { expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'Label1' } }, 'field_add'); }); - test('drop function is not called on dropType undefined', async () => { + test('drop function is not called on dropTypes undefined', async () => { const preventDefault = jest.fn(); const stopPropagation = jest.fn(); const setDragging = jest.fn(); @@ -156,29 +162,29 @@ describe('DragDrop', () => { dragging={{ id: 'hi', humanData: { label: 'Label1' } }} setDragging={setDragging} > - + ); - component - .find('[data-test-subj="lnsDragDrop"]') - .simulate('drop', { preventDefault, stopPropagation }); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(0); + dragDrop.simulate('dragover'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); - expect(preventDefault).toBeCalled(); - expect(stopPropagation).toBeCalled(); - expect(setDragging).toBeCalledWith(undefined); + expect(preventDefault).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + expect(setDragging).not.toHaveBeenCalled(); expect(onDrop).not.toHaveBeenCalled(); }); - test('defined dropType is reflected in the className', () => { + test('defined dropTypes is reflected in the className', () => { const component = render( { throw x; }} - dropType="field_add" + dropTypes={['field_add']} value={value} order={[2, 0, 1, 0]} > @@ -189,7 +195,7 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); - test('items that has dropType=undefined get special styling when another item is dragged', () => { + test('items that has dropTypes=undefined get special styling when another item is dragged', () => { const component = mount( @@ -198,7 +204,7 @@ describe('DragDrop', () => { {}} - dropType={undefined} + dropTypes={undefined} value={{ id: '2', humanData: { label: 'label2' } }} > @@ -235,7 +241,7 @@ describe('DragDrop', () => { order={[2, 0, 1, 0]} value={value} onDrop={(x: unknown) => {}} - dropType="field_add" + dropTypes={['field_add']} getAdditionalClassesOnEnter={getAdditionalClassesOnEnter} getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -248,11 +254,14 @@ describe('DragDrop', () => { .find('[data-test-subj="lnsDragDrop"]') .first() .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(setA11yMessage).toBeCalledWith('Lifted ignored'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(1); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop'); expect(component.find('.additional')).toHaveLength(0); }); @@ -287,7 +296,7 @@ describe('DragDrop', () => { order={[2, 0, 1, 0]} value={value} onDrop={(x: unknown) => {}} - dropType="field_add" + dropTypes={['field_add']} getAdditionalClassesOnEnter={getAdditionalClasses} getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -300,219 +309,652 @@ describe('DragDrop', () => { .find('[data-test-subj="lnsDragDrop"]') .first() .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - expect(component.find('.additional')).toHaveLength(1); + expect(component.find('.additional')).toHaveLength(2); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); expect(setActiveDropTarget).toBeCalledWith(undefined); }); - test('Keyboard navigation: User receives proper drop Targets highlighted when pressing arrow keys', () => { - const onDrop = jest.fn(); - const setActiveDropTarget = jest.fn(); - const setA11yMessage = jest.fn(); - const items = [ - { - draggable: true, - value: { - id: '1', - humanData: { label: 'Label1', position: 1 }, + describe('Keyboard navigation', () => { + test('User receives proper drop Targets highlighted when pressing arrow keys', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], }, - children: '1', - order: [2, 0, 0, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', + { + draggable: true, + dragType: 'move' as 'copy' | 'move', - value: { - id: '2', + value: { + id: '2', - humanData: { label: 'label2', position: 1 }, - }, - onDrop, - dropType: 'move_compatible' as DropType, - order: [2, 0, 1, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', - value: { - id: '3', - humanData: { label: 'label3', position: 1, groupLabel: 'Y' }, + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible'] as DropType[], + order: [2, 0, 1, 0], }, - onDrop, - dropType: 'replace_compatible' as DropType, - order: [2, 0, 2, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', - value: { - id: '4', - humanData: { label: 'label4', position: 2, groupLabel: 'Y' }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '3', + humanData: { + label: 'label3', + position: 1, + groupLabel: 'Y', + canSwap: true, + canDuplicate: true, + }, + }, + onDrop, + dropTypes: [ + 'replace_compatible', + 'duplicate_compatible', + 'swap_compatible', + ] as DropType[], + order: [2, 0, 2, 0], }, - order: [2, 0, 2, 1], - }, - ]; - const component = mount( - , style: {} } }, - setActiveDropTarget, - setA11yMessage, - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, - '2,0,2,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '4', + humanData: { label: 'label4', position: 2, groupLabel: 'Y' }, }, - keyboardMode: true, - }} - > - {items.map((props) => ( - -
- - ))} - - ); - const keyboardHandler = component - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') - .first() - .simulate('focus'); - act(() => { + order: [2, 0, 2, 1], + }, + ]; + const component = mount( + , style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + '2,0,2,0,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, + '2,0,1,0,1': { ...items[1].value, onDrop, dropType: 'duplicate_compatible' }, + '2,0,1,0,2': { ...items[1].value, onDrop, dropType: 'swap_compatible' }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + keyboardHandler.simulate('keydown', { key: 'ArrowRight' }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropTypes![0], + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + `You're dragging Label1 from at position 1 over label3 from Y group at position 1. Press space or enter to replace label3 with Label1. Hold alt or option to duplicate. Hold shift to swap.` + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'Label1', position: 1 }, id: '1' }, + 'move_compatible' + ); }); - expect(setActiveDropTarget).toBeCalledWith({ - ...items[2].value, - onDrop, - dropType: items[2].dropType, + + test('dragstart sets dragging in the context and calls it with proper params', async () => { + const setDragging = jest.fn(); + + const setA11yMessage = jest.fn(); + const component = mount( + + + + + + ); + + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + + keyboardHandler.simulate('keydown', { key: 'Enter' }); + act(() => { + jest.runAllTimers(); + }); + + expect(setDragging).toBeCalledWith({ + ...value, + ghost: { + children: , + style: { + height: 0, + width: 0, + }, + }, + }); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); - keyboardHandler.simulate('keydown', { key: 'Enter' }); - expect(setA11yMessage).toBeCalledWith( - 'Replace label3 in Y group at position 1 with Label1. Press space or enter to replace' - ); - expect(setActiveDropTarget).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith( - { humanData: { label: 'Label1', position: 1 }, id: '1' }, - 'move_compatible' - ); - }); - test('Keyboard navigation: dragstart sets dragging in the context and calls it with proper params', async () => { - const setDragging = jest.fn(); + test('ActiveDropTarget gets ghost image', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', - const setA11yMessage = jest.fn(); - const component = mount( - - - - - - ); + value: { + id: '2', - const keyboardHandler = component - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') - .first() - .simulate('focus'); - - keyboardHandler.simulate('keydown', { key: 'Enter' }); - jest.runAllTimers(); - - expect(setDragging).toBeCalledWith({ - ...value, - ghost: { - children: , - style: { - height: 0, - width: 0, + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible'] as DropType[], + order: [2, 0, 1, 0], }, - }, + ]; + const component = mount( + Hello
, style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + + expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); }); - expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); - test('Keyboard navigation: ActiveDropTarget gets ghost image', () => { + describe('multiple drop targets', () => { + let activeDropTarget: DragContextState['activeDropTarget']; const onDrop = jest.fn(); - const setActiveDropTarget = jest.fn(); + let setActiveDropTarget = jest.fn(); const setA11yMessage = jest.fn(); - const items = [ - { - draggable: true, - value: { - id: '1', - humanData: { label: 'Label1', position: 1 }, - }, - children: '1', - order: [2, 0, 0, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', + let component: ReactWrapper; + beforeEach(() => { + activeDropTarget = undefined; + setActiveDropTarget = jest.fn((val) => { + activeDropTarget = value as DragContextState['activeDropTarget']; + }); + component = mount( + true} + dropTargetsByOrder={undefined} + registerDropTarget={jest.fn()} + > + + + +
{dropType}
} + > + +
+
+ ); + }); + test('extra drop targets render correctly', () => { + expect(component.find('.extraDrop').hostNodes()).toHaveLength(2); + }); - value: { - id: '2', + test('extra drop targets appear when dragging over and disappear when activeDropTarget changes', () => { + component.find('[data-test-subj="lnsDragDropContainer"]').first().simulate('dragenter'); - humanData: { label: 'label2', position: 1 }, - }, + // customDropTargets are visible + expect(component.find('[data-test-subj="lnsDragDropContainer"]').prop('className')).toEqual( + 'lnsDragDrop__container lnsDragDrop__container-active' + ); + expect( + component.find('[data-test-subj="lnsDragDropExtraDrops"]').first().prop('className') + ).toEqual('lnsDragDrop__extraDrops lnsDragDrop__extraDrops-visible'); + + // set activeDropTarget as undefined + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + act(() => { + jest.runAllTimers(); + }); + component.update(); + + // customDropTargets are invisible + expect( + component.find('[data-test-subj="lnsDragDropExtraDrops"]').first().prop('className') + ).toEqual('lnsDragDrop__extraDrops'); + }); + + test('dragging over different drop types of the same value assigns correct activeDropTarget', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + component.find('SingleDropInner').at(0).simulate('dragover'); + + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'move_compatible', onDrop, - dropType: 'move_compatible' as DropType, - order: [2, 0, 1, 0], - }, - ]; - const component = mount( - Hello
, style: {} } }, - setActiveDropTarget, - setA11yMessage, - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }); + + component.find('SingleDropInner').at(1).simulate('dragover'); + + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'duplicate_compatible', + onDrop, + }); + + component.find('SingleDropInner').at(2).simulate('dragover'); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'swap_compatible', + onDrop, + }); + component.find('SingleDropInner').at(2).simulate('dragleave'); + expect(setActiveDropTarget).toBeCalledWith(undefined); + }); + + test('drop on extra drop target passes correct dropType to onDrop', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + component.find('SingleDropInner').at(0).simulate('dragover'); + component.find('SingleDropInner').at(0).simulate('drop'); + expect(onDrop).toBeCalledWith({ humanData: { label: 'Label1' }, id: '1' }, 'move_compatible'); + + component.find('SingleDropInner').at(1).simulate('dragover'); + component.find('SingleDropInner').at(1).simulate('drop'); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'Label1' }, id: '1' }, + 'duplicate_compatible' + ); + + component.find('SingleDropInner').at(2).simulate('dragover'); + component.find('SingleDropInner').at(2).simulate('drop'); + expect(onDrop).toBeCalledWith({ humanData: { label: 'Label1' }, id: '1' }, 'swap_compatible'); + }); + + test('pressing Alt or Shift when dragging over the main drop target sets extra drop target as active', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + // needed to setup activeDropType + component + .find('SingleDropInner') + .at(0) + .simulate('dragover', { altKey: true }) + .simulate('dragover', { altKey: true }); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'duplicate_compatible', + onDrop, + }); + + component + .find('SingleDropInner') + .at(0) + .simulate('dragover', { shiftKey: true }) + .simulate('dragover', { shiftKey: true }); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'swap_compatible', + onDrop, + }); + }); + + test('pressing Alt or Shift when dragging over the extra drop target does nothing', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + const extraDrop = component.find('SingleDropInner').at(1); + extraDrop.simulate('dragover', { altKey: true }); + extraDrop.simulate('dragover', { shiftKey: true }); + extraDrop.simulate('dragover'); + expect( + setActiveDropTarget.mock.calls.every((call) => call[0].dropType === 'duplicate_compatible') + ); + }); + describe('keyboard navigation', () => { + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, }, - keyboardMode: true, - }} - > - {items.map((props) => ( - -
- - ))} - - ); + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as const, - expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'] as DropType[], + order: [2, 0, 1, 0], + }, + { + draggable: true, + dragType: 'move' as const, + value: { + id: '3', + humanData: { label: 'label3', position: 1, groupLabel: 'Y' }, + }, + onDrop, + dropTypes: ['replace_compatible'] as DropType[], + order: [2, 0, 2, 0], + }, + ]; + const assignedDropTargetsByOrder: DragContextState['dropTargetsByOrder'] = { + '2,0,1,0,0': { + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }, + '2,0,1,0,1': { + dropType: 'duplicate_compatible', + humanData: { + label: 'label2', + position: 1, + }, + id: '2', + onDrop, + }, + '2,0,1,0,2': { + dropType: 'swap_compatible', + humanData: { + label: 'label2', + position: 1, + }, + id: '2', + onDrop, + }, + '2,0,2,0,0': { + dropType: 'replace_compatible', + humanData: { + groupLabel: 'Y', + label: 'label3', + position: 1, + }, + id: '3', + onDrop, + }, + }; + test('when pressing enter key, context receives the proper dropTargetsByOrder', () => { + let dropTargetsByOrder: DragContextState['dropTargetsByOrder'] = {}; + const setKeyboardMode = jest.fn(); + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget, + dropTargetsByOrder, + keyboardMode: true, + setKeyboardMode, + registerDropTarget: jest.fn((order, dropTarget) => { + dropTargetsByOrder = { + ...dropTargetsByOrder, + [order.join(',')]: dropTarget, + }; + }), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]').first().simulate('focus'); + act(() => { + jest.runAllTimers(); + }); + component.update(); + expect(dropTargetsByOrder).toEqual(assignedDropTargetsByOrder); + }); + test('when pressing ArrowRight key with modifier key pressed in, the extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: undefined, + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'ArrowRight', altKey: true }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'duplicate_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'ArrowRight', shiftKey: true }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'swap_compatible', + }); + }); + test('when having a main target selected and pressing alt, the first extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: assignedDropTargetsByOrder['2,0,1,0,0'], + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'Alt' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'duplicate_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keyup', { key: 'Alt' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }); + }); + test('when having a main target selected and pressing shift, the second extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: assignedDropTargetsByOrder['2,0,1,0,0'], + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'Shift' }); + }); + + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'swap_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keyup', { key: 'Shift' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }); + }); + }); }); - describe('reordering', () => { + describe('Reordering', () => { const onDrop = jest.fn(); const items = [ { id: '1', humanData: { label: 'Label1', position: 1, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, + draggable: true, }, { id: '2', humanData: { label: 'label2', position: 2, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, }, { id: '3', humanData: { label: 'label3', position: 3, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, }, ]; const mountComponent = ( @@ -546,7 +988,6 @@ describe('DragDrop', () => { const dragDropSharedProps = { draggable: true, dragType: 'move' as 'copy' | 'move', - dropType: 'reorder' as DropType, reorderableGroup: items.map(({ id }) => ({ id })), onDrop: onDropHandler || onDrop, }; @@ -557,15 +998,25 @@ describe('DragDrop', () => { 1 - + 2 - + 3 @@ -574,7 +1025,10 @@ describe('DragDrop', () => { }; test(`Inactive group renders properly`, () => { const component = mountComponent(undefined); - expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(3); + act(() => { + jest.runAllTimers(); + }); + expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(5); }); test(`Reorderable group with lifted element renders properly`, () => { @@ -585,31 +1039,32 @@ describe('DragDrop', () => { setDragging, setA11yMessage, }); + + act(() => { + jest.runAllTimers(); + }); + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + act(() => { - component - .find('[data-test-subj="lnsDragDrop"]') - .first() - .simulate('dragstart', { dataTransfer }); jest.runAllTimers(); }); expect(setDragging).toBeCalledWith({ ...items[0] }); expect(setA11yMessage).toBeCalledWith('Lifted Label1'); - expect( - component - .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') - .hasClass('lnsDragDrop-isActiveGroup') - ).toEqual(true); }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { const component = mountComponent({ dragging: { ...items[0] } }); + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + act(() => { - component - .find('[data-test-subj="lnsDragDrop"]') - .first() - .simulate('dragstart', { dataTransfer }); jest.runAllTimers(); }); @@ -656,14 +1111,16 @@ describe('DragDrop', () => { setA11yMessage, }); - component - .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') - .at(1) - .simulate('drop', { preventDefault, stopPropagation }); - jest.runAllTimers(); + const dragDrop = component.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]').at(1); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); + + act(() => { + jest.runAllTimers(); + }); expect(setA11yMessage).toBeCalledWith( - 'Reordered Label1 in X group from position 1 to positon 3' + 'Reordered Label1 in X group from position 1 to position 3' ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); @@ -685,7 +1142,9 @@ describe('DragDrop', () => { setActiveDropTarget, setA11yMessage, }); - const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first(); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); @@ -694,11 +1153,12 @@ describe('DragDrop', () => { keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(setActiveDropTarget).toBeCalledWith(items[1]); + expect(setActiveDropTarget).toBeCalledWith({ ...items[1], dropType: 'reorder' }); expect(setA11yMessage).toBeCalledWith( 'Reorder Label1 in X group from position 1 to position 2. Press space or enter to reorder' ); }); + test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ dragging: { ...items[0] }, @@ -712,6 +1172,7 @@ describe('DragDrop', () => { }); const keyboardHandler = component .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() .simulate('focus'); act(() => { @@ -732,7 +1193,9 @@ describe('DragDrop', () => { const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'Escape' }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(onDropHandler).not.toHaveBeenCalled(); expect(setA11yMessage).toBeCalledWith( @@ -828,7 +1291,7 @@ describe('DragDrop', () => { ; +const noop = () => {}; + /** * The base props to the DragDrop component. */ @@ -53,7 +57,7 @@ interface BaseProps { /** * The React element which will be passed the draggable handlers */ - children: React.ReactElement; + children: ReactElement; /** * Indicates whether or not this component is draggable. */ @@ -85,14 +89,18 @@ interface BaseProps { dragType?: 'copy' | 'move'; /** - * Indicates the type of a drop - when undefined, the currently dragged item + * Indicates the type of drop targets - when undefined, the currently dragged item * cannot be dropped onto this component. */ - dropType?: DropType; + dropTypes?: DropType[]; /** * Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically */ order: number[]; + /** + * Extra drop targets by dropType + */ + getCustomDropTarget?: (dropType: DropType) => ReactElement | null; } /** @@ -109,19 +117,17 @@ interface DragInnerProps extends BaseProps { dropTargetsByOrder: DragContextState['dropTargetsByOrder']; }; onDragStart?: ( - target?: - | DroppableEvent['currentTarget'] - | React.KeyboardEvent['currentTarget'] + target?: DroppableEvent['currentTarget'] | KeyboardEvent['currentTarget'] ) => void; onDragEnd?: () => void; - extraKeyboardHandler?: (e: React.KeyboardEvent) => void; + extraKeyboardHandler?: (e: KeyboardEvent) => void; ariaDescribedBy?: string; } /** * The props for a non-draggable instance of that component. */ -interface DropInnerProps extends BaseProps { +interface DropsInnerProps extends BaseProps { dragging: DragContextState['dragging']; keyboardMode: DragContextState['keyboardMode']; setKeyboardMode: DragContextState['setKeyboardMode']; @@ -129,7 +135,7 @@ interface DropInnerProps extends BaseProps { setActiveDropTarget: DragContextState['setActiveDropTarget']; setA11yMessage: DragContextState['setA11yMessage']; registerDropTarget: DragContextState['registerDropTarget']; - isActiveDropTarget: boolean; + activeDropTarget: DragContextState['activeDropTarget']; isNotDroppable: boolean; } @@ -148,7 +154,7 @@ export const DragDrop = (props: BaseProps) => { setA11yMessage, } = useContext(DragContext); - const { value, draggable, dropType, reorderableGroup } = props; + const { value, draggable, dropTypes, reorderableGroup } = props; const isDragging = !!(draggable && value.id === dragging?.id); const activeDraggingProps = isDragging @@ -159,7 +165,7 @@ export const DragDrop = (props: BaseProps) => { } : undefined; - if (draggable && !dropType) { + if (draggable && (!dropTypes || !dropTypes.length)) { const dragProps = { ...props, activeDraggingProps, @@ -175,14 +181,13 @@ export const DragDrop = (props: BaseProps) => { } } - const isActiveDropTarget = Boolean(activeDropTarget?.id === value.id); const dropProps = { ...props, keyboardMode, setKeyboardMode, dragging, setDragging, - isActiveDropTarget, + activeDropTarget, setActiveDropTarget, registerDropTarget, setA11yMessage, @@ -190,19 +195,20 @@ export const DragDrop = (props: BaseProps) => { // If the configuration has provided a droppable flag, but this particular item is not // droppable, then it should be less prominent. Ignores items that are both // draggable and drop targets - !!(!dropType && dragging && value.id !== dragging.id), + !!((!dropTypes || !dropTypes.length) && dragging && value.id !== dragging.id), }; if ( reorderableGroup && reorderableGroup.length > 1 && - reorderableGroup?.some((i) => i.id === dragging?.id) + reorderableGroup?.some((i) => i.id === dragging?.id) && + dropTypes?.[0] === 'reorder' ) { return ; } - return ; + return ; }; -const removeSelectionBeforeDragging = () => { +const removeSelection = () => { const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); @@ -230,8 +236,60 @@ const DragInner = memo(function DragInner({ const activeDropTarget = activeDraggingProps?.activeDropTarget; const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder; + const setTarget = useCallback( + (target?: DropIdentifier, announceModifierKeys = false) => { + setActiveDropTarget(target); + setA11yMessage( + target + ? announce.selectedTarget( + value.humanData, + target?.humanData, + target?.dropType, + announceModifierKeys + ) + : announce.noTarget() + ); + }, + [setActiveDropTarget, setA11yMessage, value.humanData] + ); + + const setTargetOfIndex = useCallback( + (id: string, index: number) => { + const dropTargetsForActiveId = + dropTargetsByOrder && + Object.values(dropTargetsByOrder).filter((dropTarget) => dropTarget?.id === id); + if (index > 0 && dropTargetsForActiveId?.[index]) { + setTarget(dropTargetsForActiveId[index]); + } else { + setTarget(dropTargetsForActiveId?.[0], true); + } + }, + [dropTargetsByOrder, setTarget] + ); + const modifierHandlers = useMemo(() => { + const onKeyUp = (e: KeyboardEvent) => { + if ((e.key === 'Shift' || e.key === 'Alt') && activeDropTarget?.id) { + if (e.altKey) { + setTargetOfIndex(activeDropTarget.id, 1); + } else if (e.shiftKey) { + setTargetOfIndex(activeDropTarget.id, 2); + } else { + setTargetOfIndex(activeDropTarget.id, 0); + } + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt' && activeDropTarget?.id) { + setTargetOfIndex(activeDropTarget.id, 1); + } else if (e.key === 'Shift' && activeDropTarget?.id) { + setTargetOfIndex(activeDropTarget.id, 2); + } + }; + return { onKeyDown, onKeyUp }; + }, [activeDropTarget, setTargetOfIndex]); + const dragStart = ( - e: DroppableEvent | React.KeyboardEvent, + e: DroppableEvent | KeyboardEvent, keyboardModeOn?: boolean ) => { // Setting stopPropgagation causes Chrome failures, so @@ -282,20 +340,8 @@ const DragInner = memo(function DragInner({ onDragEnd(); } }; - const dropToActiveDropTarget = () => { - if (activeDropTarget) { - trackUiEvent('drop_total'); - const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget; - setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); - onTargetDrop(value, dropType); - } - }; - - const setNextTarget = (reversed = false) => { - if (!order) { - return; - } + const setNextTarget = (e: KeyboardEvent, reversed = false) => { const nextTarget = nextValidDropTarget( dropTargetsByOrder, activeDropTarget, @@ -304,13 +350,24 @@ const DragInner = memo(function DragInner({ reversed ); - setActiveDropTarget(nextTarget); - setA11yMessage( - nextTarget - ? announce.selectedTarget(value.humanData, nextTarget?.humanData, nextTarget?.dropType) - : announce.noTarget() - ); + if (e.altKey && nextTarget?.id) { + setTargetOfIndex(nextTarget.id, 1); + } else if (e.shiftKey && nextTarget?.id) { + setTargetOfIndex(nextTarget.id, 2); + } else { + setTarget(nextTarget, true); + } }; + + const dropToActiveDropTarget = () => { + if (activeDropTarget) { + trackUiEvent('drop_total'); + const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget; + setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); + onTargetDrop(value, dropType); + } + }; + const shouldShowGhostImageInstead = dragType === 'move' && keyboardMode && @@ -319,7 +376,9 @@ const DragInner = memo(function DragInner({ return (
@@ -334,7 +393,7 @@ const DragInner = memo(function DragInner({ dragEnd(); } }} - onKeyDown={(e: React.KeyboardEvent) => { + onKeyDown={(e: KeyboardEvent) => { const { key } = e; if (key === keys.ENTER || key === keys.SPACE) { if (activeDropTarget) { @@ -356,30 +415,30 @@ const DragInner = memo(function DragInner({ if (extraKeyboardHandler) { extraKeyboardHandler(e); } - if (keyboardMode && (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key)) { - setNextTarget(!!(keys.ARROW_LEFT === key)); + if (keyboardMode) { + if (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key) { + setNextTarget(e, !!(keys.ARROW_LEFT === key)); + } + modifierHandlers.onKeyDown(e); } }} + onKeyUp={modifierHandlers.onKeyUp} /> {React.cloneElement(children, { 'data-test-subj': dataTestSubj || 'lnsDragDrop', - className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable', { - 'lnsDragDrop-isHidden': - (activeDraggingProps && dragType === 'move' && !keyboardMode) || - shouldShowGhostImageInstead, - }), + className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable'), draggable: true, onDragEnd: dragEnd, onDragStart: dragStart, - onMouseDown: removeSelectionBeforeDragging, + onMouseDown: removeSelection, })}
); }); -const DropInner = memo(function DropInner(props: DropInnerProps) { +const DropsInner = memo(function DropsInner(props: DropsInnerProps) { const { dataTestSubj, className, @@ -389,54 +448,86 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { draggable, dragging, isNotDroppable, - dropType, + dropTypes, order, getAdditionalClassesOnEnter, getAdditionalClassesOnDroppable, - isActiveDropTarget, + activeDropTarget, registerDropTarget, setActiveDropTarget, keyboardMode, setKeyboardMode, setDragging, setA11yMessage, + getCustomDropTarget, } = props; + const [isInZone, setIsInZone] = useState(false); + const mainTargetRef = useRef(null); + useShallowCompareEffect(() => { - if (dropType && onDrop && keyboardMode) { - registerDropTarget(order, { ...value, onDrop, dropType }); + if (dropTypes && dropTypes?.[0] && onDrop && keyboardMode) { + dropTypes.forEach((dropType, index) => { + registerDropTarget([...order, index], { ...value, onDrop, dropType }); + }); return () => { - registerDropTarget(order, undefined); + dropTypes.forEach((_, index) => { + registerDropTarget([...order, index], undefined); + }); }; } - }, [order, value, registerDropTarget, dropType, keyboardMode]); - - const classesOnEnter = getAdditionalClassesOnEnter?.(dropType); - const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); - - const classes = classNames( - 'lnsDragDrop', - { - 'lnsDragDrop-isDraggable': draggable, - 'lnsDragDrop-isDroppable': !draggable, - 'lnsDragDrop-isDropTarget': dropType && dropType !== 'reorder', - 'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget && dropType !== 'reorder', - 'lnsDragDrop-isNotDroppable': isNotDroppable, - }, - classesOnEnter && { [classesOnEnter]: isActiveDropTarget }, - classesOnDroppable && { [classesOnDroppable]: dropType } - ); + }, [order, registerDropTarget, dropTypes, keyboardMode]); - const dragOver = (e: DroppableEvent) => { - if (!dropType) { - return; + useEffect(() => { + if (activeDropTarget && activeDropTarget.id !== value.id) { + setIsInZone(false); } + setTimeout(() => { + if (!activeDropTarget) { + setIsInZone(false); + } + }, 1000); + }, [activeDropTarget, setIsInZone, value.id]); + + const dragEnter = () => { + if (!isInZone) { + setIsInZone(true); + } + }; + + const getModifiedDropType = (e: DroppableEvent, dropType: DropType) => { + if (!dropTypes || dropTypes.length <= 1) { + return dropType; + } + const dropIndex = dropTypes.indexOf(dropType); + if (dropIndex > 0) { + return dropType; + } else if (dropIndex === 0) { + if (e.altKey && dropTypes[1]) { + return dropTypes[1]; + } else if (e.shiftKey && dropTypes[2]) { + return dropTypes[2]; + } + } + return dropType; + }; + + const dragOver = (e: DroppableEvent, dropType: DropType) => { e.preventDefault(); + if (!dragging || !onDrop) { + return; + } + const modifiedDropType = getModifiedDropType(e, dropType); + const isActiveDropTarget = !!( + activeDropTarget?.id === value.id && activeDropTarget?.dropType === modifiedDropType + ); // An optimization to prevent a bunch of React churn. - if (!isActiveDropTarget && dragging && onDrop) { - setActiveDropTarget({ ...value, dropType, onDrop }); - setA11yMessage(announce.selectedTarget(dragging.humanData, value.humanData, dropType)); + if (!isActiveDropTarget) { + setActiveDropTarget({ ...value, dropType: modifiedDropType, onDrop }); + setA11yMessage( + announce.selectedTarget(dragging.humanData, value.humanData, modifiedDropType) + ); } }; @@ -444,35 +535,146 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { setActiveDropTarget(undefined); }; - const drop = (e: DroppableEvent | React.KeyboardEvent) => { + const drop = (e: DroppableEvent, dropType: DropType) => { e.preventDefault(); e.stopPropagation(); - - if (onDrop && dropType && dragging) { - trackUiEvent('drop_total'); - onDrop(dragging, dropType); + setIsInZone(false); + if (onDrop && dragging) { + const modifiedDropType = getModifiedDropType(e, dropType); + onDrop(dragging, modifiedDropType); setTimeout(() => - setA11yMessage(announce.dropped(dragging.humanData, value.humanData, dropType)) + setA11yMessage(announce.dropped(dragging.humanData, value.humanData, modifiedDropType)) ); } + setDragging(undefined); setActiveDropTarget(undefined); setKeyboardMode(false); }; - const ghost = - isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined; + const getProps = (dropType?: DropType, dropChildren?: ReactElement) => { + const isActiveDropTarget = Boolean( + activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType + ); + return { + 'data-test-subj': dataTestSubj || 'lnsDragDrop', + className: getClasses(dropType, dropChildren), + onDragEnter: dragEnter, + onDragLeave: dragLeave, + onDragOver: dropType ? (e: DroppableEvent) => dragOver(e, dropType) : noop, + onDrop: dropType ? (e: DroppableEvent) => drop(e, dropType) : noop, + draggable, + ghost: + (isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost && dragging.ghost) || + undefined, + }; + }; + + const getClasses = (dropType?: DropType, dropChildren = children) => { + const isActiveDropTarget = Boolean( + activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType + ); + const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); + + const classes = classNames( + 'lnsDragDrop', + { + 'lnsDragDrop-isDraggable': draggable, + 'lnsDragDrop-isDroppable': !draggable, + 'lnsDragDrop-isDropTarget': dropType, + 'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget, + 'lnsDragDrop-isNotDroppable': isNotDroppable, + }, + classesOnDroppable && { [classesOnDroppable]: dropType } + ); + return classNames(classes, className, dropChildren.props.className); + }; + + const getMainTargetClasses = () => { + const classesOnEnter = getAdditionalClassesOnEnter?.(activeDropTarget?.dropType); + return classNames(classesOnEnter && { [classesOnEnter]: activeDropTarget?.id === value.id }); + }; + + const mainTargetProps = getProps(dropTypes && dropTypes[0]); + + const extraDropStyles = useMemo(() => { + const extraDrops = dropTypes && dropTypes.length && dropTypes.slice(1); + if (!extraDrops || !extraDrops.length) { + return; + } + + const height = extraDrops.length * 40; + const minHeight = height - (mainTargetRef.current?.clientHeight || 40); + const clipPath = `polygon(100% 0px, 100% ${height - minHeight}px, 0 100%, 0 0)`; + return { + clipPath, + height, + }; + }, [dropTypes]); + + return ( +
+ + {dropTypes && dropTypes.length > 1 && ( + <> +
+ + {dropTypes.slice(1).map((dropType) => { + const dropChildren = getCustomDropTarget?.(dropType); + return dropChildren ? ( + + + {dropChildren} + + + ) : null; + })} + + + )} +
+ ); +}); +const SingleDropInner = ({ + ghost, + children, + ...rest +}: { + ghost?: Ghost; + children: ReactElement; + style?: React.CSSProperties; + className?: string; +}) => { return ( <> - {React.cloneElement(children, { - 'data-test-subj': dataTestSubj || 'lnsDragDrop', - className: classNames(children.props.className, classes, className), - onDragOver: dragOver, - onDragLeave: dragLeave, - onDrop: drop, - draggable, - })} + {React.cloneElement(children, rest)} {ghost ? React.cloneElement(ghost.children, { className: classNames(ghost.children.props.className, 'lnsDragDrop_ghost'), @@ -481,7 +683,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { : null} ); -}); +}; const ReorderableDrag = memo(function ReorderableDrag( props: DragInnerProps & { reorderableGroup: Array<{ id: string }>; dragging?: DragDropIdentifier } @@ -519,7 +721,7 @@ const ReorderableDrag = memo(function ReorderableDrag( const onReorderableDragStart = ( currentTarget?: | DroppableEvent['currentTarget'] - | React.KeyboardEvent['currentTarget'] + | KeyboardEvent['currentTarget'] ) => { if (currentTarget) { const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; @@ -540,7 +742,7 @@ const ReorderableDrag = memo(function ReorderableDrag( reorderedItems: [], })); - const extraKeyboardHandler = (e: React.KeyboardEvent) => { + const extraKeyboardHandler = (e: KeyboardEvent) => { if (isReorderOn && keyboardMode) { e.stopPropagation(); e.preventDefault(); @@ -644,7 +846,7 @@ const ReorderableDrag = memo(function ReorderableDrag( }); const ReorderableDrop = memo(function ReorderableDrop( - props: DropInnerProps & { reorderableGroup: Array<{ id: string }> } + props: DropsInnerProps & { reorderableGroup: Array<{ id: string }> } ) { const { onDrop, @@ -652,11 +854,10 @@ const ReorderableDrop = memo(function ReorderableDrop( dragging, setDragging, setKeyboardMode, - isActiveDropTarget, + activeDropTarget, setActiveDropTarget, reorderableGroup, setA11yMessage, - dropType, } = props; const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); @@ -666,7 +867,7 @@ const ReorderableDrop = memo(function ReorderableDrop( setReorderState, } = useContext(ReorderContext); - const heightRef = React.useRef(null); + const heightRef = useRef(null); const isReordered = isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length; @@ -688,42 +889,38 @@ const ReorderableDrop = memo(function ReorderableDrop( }, [isReordered, setReorderState, value.id]); const onReorderableDragOver = (e: DroppableEvent) => { - if (!dropType) { - return; - } e.preventDefault(); - // An optimization to prevent a bunch of React churn. - if (!isActiveDropTarget && dropType && onDrop) { - setActiveDropTarget({ ...value, dropType, onDrop }); - } + if (activeDropTarget?.id !== value?.id && onDrop) { + setActiveDropTarget({ ...value, dropType: 'reorder', onDrop }); - const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); - if (!dragging || draggingIndex === -1) { - return; - } - const droppingIndex = currentIndex; - if (draggingIndex === droppingIndex) { - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - } + if (!dragging || draggingIndex === -1) { + return; + } + const droppingIndex = currentIndex; + if (draggingIndex === droppingIndex) { + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + } - setReorderState((s: ReorderState) => - draggingIndex < droppingIndex - ? { - ...s, - reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), - direction: '-', - } - : { - ...s, - reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), - direction: '+', - } - ); + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { + ...s, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', + } + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + } }; const onReorderableDrop = (e: DroppableEvent) => { @@ -734,7 +931,7 @@ const ReorderableDrop = memo(function ReorderableDrop( setDragging(undefined); setKeyboardMode(false); - if (onDrop && dropType && dragging) { + if (onDrop && dragging) { trackUiEvent('drop_total'); onDrop(dragging, 'reorder'); // setTimeout ensures it will run after dragEnd messaging @@ -758,17 +955,18 @@ const ReorderableDrop = memo(function ReorderableDrop( data-test-subj="lnsDragDrop-translatableDrop" className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable" > - +
{ + setActiveDropTarget(undefined); setReorderState((s: ReorderState) => ({ ...s, reorderedItems: [], diff --git a/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx index 3bd1d5693005c..72771edbae981 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx @@ -9,132 +9,340 @@ import { i18n } from '@kbn/i18n'; import { DropType } from '../../types'; import { HumanData } from '.'; -type AnnouncementFunction = (draggedElement: HumanData, dropElement: HumanData) => string; +type AnnouncementFunction = ( + draggedElement: HumanData, + dropElement: HumanData, + announceModifierKeys?: boolean +) => string; interface CustomAnnouncementsType { dropped: Partial<{ [dropType in DropType]: AnnouncementFunction }>; selectedTarget: Partial<{ [dropType in DropType]: AnnouncementFunction }>; } -const selectedTargetReplace = ( - { label }: HumanData, - { label: dropLabel, groupLabel, position }: HumanData -) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replace', { - defaultMessage: `Replace {dropLabel} in {groupLabel} group at position {position} with {label}. Press space or enter to replace`, - values: { - label, - dropLabel, - groupLabel, - position, - }, - }); +const replaceAnnouncement = { + selectedTarget: ( + { label, groupLabel, position }: HumanData, + { + label: dropLabel, + groupLabel: dropGroupLabel, + position: dropPosition, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over {dropLabel} from {dropGroupLabel} group at position {dropPosition}. Press space or enter to replace {dropLabel} with {label}.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropLabel, + dropGroupLabel, + dropPosition, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } -const droppedReplace = ( - { label }: HumanData, - { label: dropLabel, groupLabel, position }: HumanData -) => - i18n.translate('xpack.lens.dragDrop.announce.duplicated.replace', { - defaultMessage: 'Replaced {dropLabel} with {label} in {groupLabel} at position {position}', - values: { - label, - dropLabel, - groupLabel, - position, - }, - }); + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replace', { + defaultMessage: `Replace {dropLabel} in {dropGroupLabel} group at position {dropPosition} with {label}. Press space or enter to replace.`, + values: { + label, + dropLabel, + dropGroupLabel, + dropPosition, + }, + }); + }, + dropped: ({ label }: HumanData, { label: dropLabel, groupLabel, position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.duplicated.replace', { + defaultMessage: 'Replaced {dropLabel} with {label} in {groupLabel} at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + }, + }), +}; + +const duplicateAnnouncement = { + selectedTarget: ( + { label, groupLabel }: HumanData, + { groupLabel: dropGroupLabel, position }: HumanData + ) => { + if (groupLabel !== dropGroupLabel) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicated', { + defaultMessage: `Duplicate {label} to {dropGroupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate`, + values: { + label, + dropGroupLabel, + position, + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicatedInGroup', { + defaultMessage: `Duplicate {label} to {dropGroupLabel} group at position {position}. Press space or enter to duplicate`, + values: { + label, + dropGroupLabel, + position, + }, + }); + }, + dropped: ({ label }: HumanData, { groupLabel, position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicated', { + defaultMessage: 'Duplicated {label} in {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + }, + }), +}; + +const reorderAnnouncement = { + selectedTarget: ( + { label, groupLabel, position: prevPosition }: HumanData, + { position }: HumanData + ) => + prevPosition === position + ? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reorderedBack', { + defaultMessage: `{label} returned to its initial position {prevPosition}`, + values: { + label, + prevPosition, + }, + }) + : i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reordered', { + defaultMessage: `Reorder {label} in {groupLabel} group from position {prevPosition} to position {position}. Press space or enter to reorder`, + values: { + groupLabel, + label, + position, + prevPosition, + }, + }), + dropped: ({ label, groupLabel, position: prevPosition }: HumanData, { position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.reordered', { + defaultMessage: + 'Reordered {label} in {groupLabel} group from position {prevPosition} to position {position}', + values: { + label, + groupLabel, + position, + prevPosition, + }, + }), +}; + +const DUPLICATE_SHORT = i18n.translate('xpack.lens.dragDrop.announce.duplicate.short', { + defaultMessage: ' Hold alt or option to duplicate.', +}); + +const SWAP_SHORT = i18n.translate('xpack.lens.dragDrop.announce.swap.short', { + defaultMessage: ' Hold shift to swap.', +}); export const announcements: CustomAnnouncementsType = { selectedTarget: { - reorder: ({ label, groupLabel, position: prevPosition }, { position }) => - prevPosition === position - ? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reorderedBack', { - defaultMessage: `{label} returned to its initial position {prevPosition}`, + reorder: reorderAnnouncement.selectedTarget, + duplicate_compatible: duplicateAnnouncement.selectedTarget, + field_replace: replaceAnnouncement.selectedTarget, + replace_compatible: replaceAnnouncement.selectedTarget, + replace_incompatible: ( + { label, groupLabel, position }: HumanData, + { + label: dropLabel, + groupLabel: dropGroupLabel, + position: dropPosition, + nextLabel, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate( + 'xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatibleMain', + { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over {dropLabel} from {dropGroupLabel} group at position {dropPosition}. Press space or enter to convert {label} to {nextLabel} and replace {dropLabel}.{duplicateCopy}{swapCopy}`, values: { label, - prevPosition, - }, - }) - : i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reordered', { - defaultMessage: `Reorder {label} in {groupLabel} group from position {prevPosition} to position {position}. Press space or enter to reorder`, - values: { groupLabel, - label, position, - prevPosition, + dropLabel, + dropGroupLabel, + dropPosition, + nextLabel, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', }, - }), - duplicate_in_group: ({ label }, { groupLabel, position }) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicated', { - defaultMessage: `Duplicate {label} to {groupLabel} group at position {position}. Press space or enter to duplicate`, + } + ); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatible', { + defaultMessage: `Convert {label} to {nextLabel} and replace {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Press space or enter to replace`, values: { label, - groupLabel, - position, + nextLabel, + dropLabel, + dropGroupLabel, + dropPosition, }, - }), - field_replace: selectedTargetReplace, - replace_compatible: selectedTargetReplace, - replace_incompatible: ( + }); + }, + move_incompatible: ( + { label, groupLabel, position }: HumanData, + { + groupLabel: dropGroupLabel, + position: dropPosition, + nextLabel, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatibleMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over position {dropPosition} in {dropGroupLabel} group. Press space or enter to convert {label} to {nextLabel} and move.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropPosition, + nextLabel, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatible', { + defaultMessage: `Convert {label} to {nextLabel} and move to {dropGroupLabel} group at position {dropPosition}. Press space or enter to move`, + values: { + label, + nextLabel, + dropGroupLabel, + dropPosition, + }, + }); + }, + + move_compatible: ( + { label, groupLabel, position }: HumanData, + { groupLabel: dropGroupLabel, position: dropPosition, canSwap, canDuplicate }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatibleMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over position {dropPosition} in {dropGroupLabel} group. Press space or enter to move.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropPosition, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatible', { + defaultMessage: `Move {label} to {dropGroupLabel} group at position {dropPosition}. Press space or enter to move`, + values: { + label, + dropGroupLabel, + dropPosition, + }, + }); + }, + duplicate_incompatible: ( { label }: HumanData, - { label: dropLabel, groupLabel, position, nextLabel }: HumanData + { groupLabel, position, nextLabel }: HumanData ) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatible', { - defaultMessage: `Convert {label} to {nextLabel} and replace {dropLabel} in {groupLabel} group at position {position}. Press space or enter to replace`, + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicateIncompatible', { + defaultMessage: + 'Convert copy of {label} to {nextLabel} and add to {groupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate', values: { label, - nextLabel, - dropLabel, groupLabel, position, + nextLabel, }, }), - move_incompatible: ( + replace_duplicate_incompatible: ( { label }: HumanData, - { label: groupLabel, position, nextLabel }: HumanData + { label: dropLabel, groupLabel, position, nextLabel }: HumanData ) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatible', { - defaultMessage: `Convert {label} to {nextLabel} and move to {groupLabel} group at position {position}. Press space or enter to move`, + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateIncompatible', { + defaultMessage: + 'Convert copy of {label} to {nextLabel} and replace {dropLabel} in {groupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate and replace', values: { label, - nextLabel, groupLabel, position, + dropLabel, + nextLabel, }, }), - move_compatible: ({ label }: HumanData, { groupLabel, position }: HumanData) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatible', { - defaultMessage: `Move {label} to {groupLabel} group at position {position}. Press space or enter to move`, + replace_duplicate_compatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateCompatible', { + defaultMessage: + 'Duplicate {label} and replace {dropLabel} in {groupLabel} at position {position}. Hold Alt or Option and press space or enter to duplicate and replace', values: { label, + dropLabel, groupLabel, position, }, }), - }, - dropped: { - reorder: ({ label, groupLabel, position: prevPosition }, { position }) => - i18n.translate('xpack.lens.dragDrop.announce.dropped.reordered', { + swap_compatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.swapCompatible', { defaultMessage: - 'Reordered {label} in {groupLabel} group from position {prevPosition} to positon {position}', + 'Swap {label} in {groupLabel} group at position {position} with {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Hold Shift and press space or enter to swap', values: { label, groupLabel, position, - prevPosition, + dropLabel, + dropGroupLabel, + dropPosition, }, }), - duplicate_in_group: ({ label }, { groupLabel, position }) => - i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicated', { - defaultMessage: 'Duplicated {label} in {groupLabel} group at position {position}', + swap_incompatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.swapIncompatible', { + defaultMessage: + 'Convert {label} to {nextLabel} in {groupLabel} group at position {position} and swap with {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Hold Shift and press space or enter to swap', values: { label, groupLabel, position, + dropLabel, + dropGroupLabel, + dropPosition, + nextLabel, }, }), - field_replace: droppedReplace, - replace_compatible: droppedReplace, + }, + dropped: { + reorder: reorderAnnouncement.dropped, + duplicate_compatible: duplicateAnnouncement.dropped, + field_replace: replaceAnnouncement.dropped, + replace_compatible: replaceAnnouncement.dropped, replace_incompatible: ( { label }: HumanData, { label: dropLabel, groupLabel, position, nextLabel }: HumanData @@ -171,6 +379,84 @@ export const announcements: CustomAnnouncementsType = { position, }, }), + + duplicate_incompatible: ( + { label }: HumanData, + { groupLabel, position, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicateIncompatible', { + defaultMessage: + 'Converted copy of {label} to {nextLabel} and added to {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + nextLabel, + }, + }), + + replace_duplicate_incompatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.replaceDuplicateIncompatible', { + defaultMessage: + 'Converted copy of {label} to {nextLabel} and replaced {dropLabel} in {groupLabel} group at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + nextLabel, + }, + }), + replace_duplicate_compatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.duplicated.replaceDuplicateCompatible', { + defaultMessage: + 'Replaced {dropLabel} with a copy of {label} in {groupLabel} at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + }, + }), + swap_compatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.swapCompatible', { + defaultMessage: + 'Moved {label} to {dropGroupLabel} at position {dropPosition} and {dropLabel} to {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + dropLabel, + dropGroupLabel, + dropPosition, + }, + }), + swap_incompatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.swapIncompatible', { + defaultMessage: + 'Converted {label} to {nextLabel} in {groupLabel} group at position {position} and swapped with {dropLabel} in {dropGroupLabel} group at position {dropPosition}', + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropLabel, + dropPosition, + nextLabel, + }, + }), }, }; @@ -256,7 +542,13 @@ export const announce = { dropped: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) => (type && announcements.dropped?.[type]?.(draggedElement, dropElement)) || defaultAnnouncements.dropped(draggedElement, dropElement), - selectedTarget: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) => - (type && announcements.selectedTarget?.[type]?.(draggedElement, dropElement)) || + selectedTarget: ( + draggedElement: HumanData, + dropElement: HumanData, + type?: DropType, + announceModifierKeys?: boolean + ) => + (type && + announcements.selectedTarget?.[type]?.(draggedElement, dropElement, announceModifierKeys)) || defaultAnnouncements.selectedTarget(draggedElement, dropElement), }; diff --git a/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx index 2c6b07ea11765..4db19e10ec701 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx @@ -135,11 +135,31 @@ export function nextValidDropTarget( return; } - const filteredTargets = Object.entries(dropTargetsByOrder).filter( - ([, dropTarget]) => dropTarget && filterElements(dropTarget) + const filteredTargets: Array<[string, DropIdentifier | undefined]> = Object.entries( + dropTargetsByOrder + ).filter(([, dropTarget]) => { + return dropTarget && filterElements(dropTarget); + }); + + // filter out secondary targets + const uniqueIdTargets = filteredTargets.reduce( + ( + acc: Array<[string, DropIdentifier | undefined]>, + current: [string, DropIdentifier | undefined] + ) => { + const [, currentDropTarget] = current; + if (!currentDropTarget) { + return acc; + } + if (acc.find(([, target]) => target?.id === currentDropTarget.id)) { + return acc; + } + return [...acc, current]; + }, + [] ); - const nextDropTargets = [...filteredTargets, draggingOrder].sort(([orderA], [orderB]) => { + const nextDropTargets = [...uniqueIdTargets, draggingOrder].sort(([orderA], [orderB]) => { const parsedOrderA = orderA.split(',').map((v) => Number(v)); const parsedOrderB = orderB.split(',').map((v) => Number(v)); diff --git a/x-pack/plugins/lens/public/drag_drop/providers/types.tsx b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx index 11f460a400dcd..8b28affa45596 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/types.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx @@ -12,6 +12,13 @@ export interface HumanData { groupLabel?: string; position?: number; nextLabel?: string; + canSwap?: boolean; + canDuplicate?: boolean; +} + +export interface Ghost { + children: React.ReactElement; + style: React.CSSProperties; } export type DragDropIdentifier = Record & { @@ -23,10 +30,7 @@ export type DragDropIdentifier = Record & { }; export type DraggingIdentifier = DragDropIdentifier & { - ghost?: { - children: React.ReactElement; - style: React.CSSProperties; - }; + ghost?: Ghost; }; export type DropIdentifier = DragDropIdentifier & { diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index 01cc4c7bc85a5..d7183263c519b 100644 --- a/x-pack/plugins/lens/public/drag_drop/readme.md +++ b/x-pack/plugins/lens/public/drag_drop/readme.md @@ -48,7 +48,7 @@ To enable dragging an item, use `DragDrop` with both a `draggable` and a `value` ## Dropping -To enable dropping, use `DragDrop` with both a `droppable` attribute and an `onDrop` handler attribute. Droppable should only be set to true if there is an item being dragged, and if a drop of the dragged item is supported. +To enable dropping, use `DragDrop` with both a `dropTypes` attribute that should be an array with at least one value and an `onDrop` handler attribute. `dropType` should only be truthy if is an item being dragged, and if a drop of the dragged item is supported. ```js const { dragging } = useContext(DragContext); @@ -56,7 +56,7 @@ const { dragging } = useContext(DragContext); return ( onChange([...items, item])} > {items.map((x) => ( @@ -85,8 +85,7 @@ The children `DragDrop` components must have props defined as in the example: i18n.translate('xpack.lens.configure.editConfig', { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx similarity index 72% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index 8449727a9e79d..212b1794d94ea 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -5,32 +5,20 @@ * 2.0. */ -import React, { useMemo, useCallback, useContext } from 'react'; -import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; - +import React, { useMemo, useCallback, useContext, ReactElement } from 'react'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation, DropType, -} from '../../../types'; -import { LayerDatasourceDropProps } from './types'; - -const getAdditionalClassesOnEnter = (dropType?: string) => { - if ( - dropType === 'field_replace' || - dropType === 'replace_compatible' || - dropType === 'replace_incompatible' - ) { - return 'lnsDragDrop-isReplacing'; - } -}; - -const getAdditionalClassesOnDroppable = (dropType?: string) => { - if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { - return 'lnsDragDrop-notCompatible'; - } -}; +} from '../../../../types'; +import { LayerDatasourceDropProps } from '../types'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getAdditionalClassesOnEnter, +} from './drop_targets_utils'; export function DraggableDimensionButton({ layerId, @@ -58,7 +46,7 @@ export function DraggableDimensionButton({ group: VisualizationDimensionGroupConfig; groups: VisualizationDimensionGroupConfig[]; label: string; - children: React.ReactElement; + children: ReactElement; layerDatasource: Datasource; layerDatasourceDropProps: LayerDatasourceDropProps; accessorIndex: number; @@ -76,8 +64,18 @@ export function DraggableDimensionButton({ dimensionGroups: groups, }); - const dropType = dropProps?.dropType; + const dropTypes = dropProps?.dropTypes; const nextLabel = dropProps?.nextLabel; + const canDuplicate = !!( + dropTypes && + (dropTypes.includes('replace_duplicate_incompatible') || + dropTypes.includes('replace_duplicate_compatible')) + ); + + const canSwap = !!( + dropTypes && + (dropTypes.includes('swap_incompatible') || dropTypes.includes('swap_compatible')) + ); const value = useMemo( () => ({ @@ -85,15 +83,28 @@ export function DraggableDimensionButton({ groupId: group.groupId, layerId, id: columnId, - dropType, + filterOperations: group.filterOperations, humanData: { + canSwap, + canDuplicate, label, groupLabel: group.groupLabel, position: accessorIndex + 1, nextLabel: nextLabel || '', }, }), - [columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel, nextLabel] + [ + columnId, + group.groupId, + accessorIndex, + layerId, + label, + group.groupLabel, + nextLabel, + group.filterOperations, + canDuplicate, + canSwap, + ] ); // todo: simplify by id and use drop targets? @@ -110,7 +121,7 @@ export function DraggableDimensionButton({ columnId, ]); - const handleOnDrop = React.useCallback( + const handleOnDrop = useCallback( (droppedItem, selectedDropType) => onDrop(droppedItem, value, selectedDropType), [value, onDrop] ); @@ -122,12 +133,13 @@ export function DraggableDimensionButton({ data-test-subj={group.dataTestSubj} > 1 ? reorderableGroup : undefined} value={value} onDrop={handleOnDrop} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx new file mode 100644 index 0000000000000..85934412dd374 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import classNames from 'classnames'; +import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DropType } from '../../../../types'; + +const getExtraDrop = ({ + type, + isIncompatible, +}: { + type: 'swap' | 'duplicate'; + isIncompatible?: boolean; +}) => { + return ( + + + + + + + + + {type === 'duplicate' + ? i18n.translate('xpack.lens.dragDrop.duplicate', { + defaultMessage: 'Duplicate', + }) + : i18n.translate('xpack.lens.dragDrop.swap', { + defaultMessage: 'Swap', + })} + + + + + + + + {' '} + {type === 'duplicate' + ? i18n.translate('xpack.lens.dragDrop.altOption', { + defaultMessage: 'Alt/Option', + }) + : i18n.translate('xpack.lens.dragDrop.shift', { + defaultMessage: 'Shift', + })} + + + + + ); +}; + +const customDropTargetsMap: Partial<{ [dropType in DropType]: React.ReactElement }> = { + replace_duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }), + duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }), + swap_incompatible: getExtraDrop({ type: 'swap', isIncompatible: true }), + replace_duplicate_compatible: getExtraDrop({ type: 'duplicate' }), + duplicate_compatible: getExtraDrop({ type: 'duplicate' }), + swap_compatible: getExtraDrop({ type: 'swap' }), +}; + +export const getCustomDropTarget = (dropType: DropType) => customDropTargetsMap?.[dropType] || null; + +export const getAdditionalClassesOnEnter = (dropType?: string) => { + if ( + dropType && + [ + 'field_replace', + 'replace_compatible', + 'replace_incompatible', + 'replace_duplicate_compatible', + 'replace_duplicate_incompatible', + ].includes(dropType) + ) { + return 'lnsDragDrop-isReplacing'; + } +}; + +export const getAdditionalClassesOnDroppable = (dropType?: string) => { + if ( + dropType && + [ + 'move_incompatible', + 'replace_incompatible', + 'swap_incompatible', + 'duplicate_incompatible', + 'replace_duplicate_incompatible', + ].includes(dropType) + ) { + return 'lnsDragDrop-notCompatible'; + } +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx similarity index 84% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index a6ccac1427fbf..cb72b986430d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -9,22 +9,17 @@ import React, { useMemo, useState, useEffect, useContext } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { generateId } from '../../../id_generator'; -import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; +import { generateId } from '../../../../id_generator'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; -import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../types'; -import { LayerDatasourceDropProps } from './types'; +import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; +import { LayerDatasourceDropProps } from '../types'; +import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', }); -const getAdditionalClassesOnDroppable = (dropType?: string) => { - if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { - return 'lnsDragDrop-notCompatible'; - } -}; - export function EmptyDimensionButton({ group, groups, @@ -69,24 +64,29 @@ export function EmptyDimensionButton({ dimensionGroups: groups, }); - const dropType = dropProps?.dropType; + const dropTypes = dropProps?.dropTypes; const nextLabel = dropProps?.nextLabel; + const canDuplicate = !!( + dropTypes && + (dropTypes.includes('duplicate_compatible') || dropTypes.includes('duplicate_incompatible')) + ); + const value = useMemo( () => ({ columnId: newColumnId, groupId: group.groupId, layerId, id: newColumnId, - dropType, humanData: { label, groupLabel: group.groupLabel, position: itemIndex + 1, nextLabel: nextLabel || '', + canDuplicate, }, }), - [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel] + [newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel, canDuplicate] ); const handleOnDrop = React.useCallback( @@ -101,7 +101,8 @@ export function EmptyDimensionButton({ value={value} order={[2, layerIndex, groupIndex, itemIndex]} onDrop={handleOnDrop} - dropType={dropType} + dropTypes={dropTypes} + getCustomDropTarget={getCustomDropTarget} >
{ + container = document.createElement('div'); + container.id = 'lensContainer'; + document.body.appendChild(container); +}); + +afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + + container = undefined; +}); + describe('ConfigPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; @@ -105,7 +121,9 @@ describe('ConfigPanel', () => { describe('focus behavior when adding or removing layers', () => { it('should focus the only layer when resetting the layer', () => { - const component = mountWithIntl(); + const component = mountWithIntl(, { + attachTo: container, + }); const firstLayerFocusable = component .find(LayerPanel) .first() @@ -126,7 +144,7 @@ describe('ConfigPanel', () => { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(); + const component = mountWithIntl(, { attachTo: container }); const secondLayerFocusable = component .find(LayerPanel) .at(1) @@ -147,7 +165,7 @@ describe('ConfigPanel', () => { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(); + const component = mountWithIntl(, { attachTo: container }); const firstLayerFocusable = component .find(LayerPanel) .first() @@ -169,7 +187,9 @@ describe('ConfigPanel', () => { } }); - const component = mountWithIntl(); + const component = mountWithIntl(, { + attachTo: container, + }); act(() => { component.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index ec4c2adba8fd7..788bf049b779b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -36,6 +36,7 @@ .lnsLayerPanel__group { padding: $euiSizeS 0; + margin-bottom: $euiSizeS; } .lnsLayerPanel__group:empty { @@ -66,8 +67,6 @@ } .lnsLayerPanel__dimension--empty { - margin-top: $euiSizeS; - &:focus, &:focus-within { @include euiFocusRing; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 30740bbd6b217..7ee7a27a53c7d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -7,22 +7,38 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { EuiFormRow } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { Visualization } from '../../../types'; +import { LayerPanel } from './layer_panel'; +import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { generateId } from '../../../id_generator'; import { createMockVisualization, createMockFramePublicAPI, createMockDatasource, DatasourceMock, } from '../../mocks'; -import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; -import { EuiFormRow } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test/jest'; -import { Visualization } from '../../../types'; -import { LayerPanel } from './layer_panel'; -import { coreMock } from 'src/core/public/mocks'; -import { generateId } from '../../../id_generator'; jest.mock('../../../id_generator'); +let container: HTMLDivElement | undefined; + +beforeEach(() => { + container = document.createElement('div'); + container.id = 'lensContainer'; + document.body.appendChild(container); +}); + +afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + + container = undefined; +}); + const defaultContext = { dragging: undefined, setDragging: jest.fn(), @@ -453,7 +469,7 @@ describe('LayerPanel', () => { }); mockDatasource.getDropProps.mockReturnValue({ - dropType: 'field_add', + dropTypes: ['field_add'], nextLabel: '', }); @@ -480,7 +496,12 @@ describe('LayerPanel', () => { }) ); - component.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop').first().simulate('drop'); + const dragDropElement = component + .find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop') + .first(); + + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -504,7 +525,7 @@ describe('LayerPanel', () => { }); mockDatasource.getDropProps.mockImplementation(({ columnId }) => - columnId !== 'a' ? { dropType: 'field_replace', nextLabel: '' } : undefined + columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); const draggingField = { @@ -532,11 +553,13 @@ describe('LayerPanel', () => { component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType') ).toEqual(undefined); - component + const dragDropElement = component .find('[data-test-subj="lnsGroup"] DragDrop') .first() - .find('.lnsLayerPanel__dimension') - .simulate('drop'); + .find('.lnsLayerPanel__dimension'); + + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); expect(mockDatasource.onDrop).not.toHaveBeenCalled(); }); @@ -566,7 +589,7 @@ describe('LayerPanel', () => { }); mockDatasource.getDropProps.mockReturnValue({ - dropType: 'replace_compatible', + dropTypes: ['replace_compatible'], nextLabel: '', }); @@ -595,7 +618,13 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(0).simulate('drop'); + + const dragDropElement = component + .find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop') + .at(0); + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -604,7 +633,14 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(1).simulate('drop'); + + const updatedDragDropElement = component + .find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop') + .at(2); + + updatedDragDropElement.simulate('dragOver'); + updatedDragDropElement.simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -642,7 +678,8 @@ describe('LayerPanel', () => { const component = mountWithIntl( - + , + { attachTo: container } ); act(() => { component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder'); @@ -695,12 +732,12 @@ describe('LayerPanel', () => { ); act(() => { - component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group'); + component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible'); }); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', - dropType: 'duplicate_in_group', + dropType: 'duplicate_compatible', droppedItem: draggingOperation, }) ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 21115285b5ce0..cf3c9099d4b0d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -25,9 +25,9 @@ import { trackUiEvent } from '../../../lens_ui_telemetry'; import { LayerPanelProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; import { RemoveLayerButton } from './remove_layer_button'; -import { EmptyDimensionButton } from './empty_dimension_button'; -import { DimensionButton } from './dimension_button'; -import { DraggableDimensionButton } from './draggable_dimension_button'; +import { EmptyDimensionButton } from './buttons/empty_dimension_button'; +import { DimensionButton } from './buttons/dimension_button'; +import { DraggableDimensionButton } from './buttons/draggable_dimension_button'; import { useFocusUpdate } from './use_focus_update'; const initialActiveDimensionState = { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index a8d8146afebb2..ffc0adf3e33ea 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -28,7 +28,8 @@ // Leave out left padding so the left sidebar's focus states are visible outside of content bounds // This also means needing to add same amount of margin to page content and suggestion items padding: $euiSize $euiSize 0; - + position: relative; + z-index: $euiZLevel1; &:first-child { padding-left: $euiSize; } @@ -55,5 +56,7 @@ padding: $euiSize $euiSizeXS $euiSize $euiSize; overflow-x: hidden; overflow-y: scroll; + padding-left: $euiFormMaxWidth + $euiSize; + margin-left: -$euiFormMaxWidth; } } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 5e5cfc3402f10..e741b9ee243db 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -955,7 +955,7 @@ describe('workspace_panel', () => { visualizationState: {}, }); initComponent(); - expect(instance.find(DragDrop).prop('dropType')).toBeTruthy(); + expect(instance.find(DragDrop).prop('dropTypes')).toBeTruthy(); }); it('should refuse to drop if there are no suggestions', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index b15b659f2d221..8a0b9922c736b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -354,7 +354,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ className="lnsWorkspacePanel__dragDrop" dataTestSubj="lnsWorkspace" draggable={false} - dropType={suggestionForDraggedField ? 'field_add' : undefined} + dropTypes={suggestionForDraggedField ? ['field_add'] : undefined} onDrop={onDrop} value={dropProps.value} order={dropProps.order} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index fef8ee171830d..e6a38ce2bb713 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -173,7 +173,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }, @@ -200,7 +200,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -827,7 +827,7 @@ describe('IndexPattern Data Panel', () => { }); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); expect(props.indexPatternFieldEditor.openEditor).toHaveBeenCalledWith( expect.objectContaining({ @@ -860,10 +860,11 @@ describe('IndexPattern Data Panel', () => { .prop('children') as ReactElement).props.items[0].props.onClick(); }); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); + await (props.indexPatternFieldEditor.openEditor as jest.Mock).mock.calls[0][0].onSave(); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); expect(props.onUpdateIndexPattern).toHaveBeenCalledWith( expect.objectContaining({ fields: [ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx index ccdb86d250962..c6ecdd73cb6ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx @@ -134,7 +134,7 @@ describe('BucketNestingEditor', () => { layer={{ columnOrder: ['a', 'b', 'c'], columns: { - a: mockCol({ operationType: 'avg', isBucketed: false }), + a: mockCol({ operationType: 'average', isBucketed: false }), b: mockCol({ operationType: 'max', isBucketed: false }), c: mockCol({ operationType: 'min', isBucketed: false }), }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index d586818cb3c11..7d1644d07d2aa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -372,7 +372,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Unique count of source', dataType: 'number', isBucketed: false, - operationType: 'cardinality', + operationType: 'unique_count', sourceField: 'source,', }, })} @@ -414,7 +414,7 @@ describe('IndexPatternDimensionEditorPanel', () => { const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).toContain( + expect(items.find(({ id }) => id === 'differences')!['data-test-subj']).toContain( 'incompatible' ); expect(items.find(({ id }) => id === 'cumulative_sum')!['data-test-subj']).toContain( @@ -462,7 +462,7 @@ describe('IndexPatternDimensionEditorPanel', () => { 'incompatible' ); - expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).not.toContain( + expect(items.find(({ id }) => id === 'differences')!['data-test-subj']).not.toContain( 'incompatible' ); expect(items.find(({ id }) => id === 'moving_average')!['data-test-subj']).not.toContain( @@ -817,7 +817,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('should select compatible operation if field not compatible with selected operation', () => { wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { ...state, @@ -825,7 +825,7 @@ describe('IndexPatternDimensionEditorPanel', () => { first: { ...state.layers.first, incompleteColumns: { - col2: { operationType: 'avg' }, + col2: { operationType: 'average' }, }, }, }, @@ -838,7 +838,7 @@ describe('IndexPatternDimensionEditorPanel', () => { .filter('[data-test-subj="indexPattern-dimension-field"]'); const options = comboBox.prop('options'); - // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation + // options[1][2] is a `source` field of type `string` which doesn't support `average` operation act(() => { comboBox.prop('onChange')!([options![1].options![2]]); }); @@ -885,7 +885,7 @@ describe('IndexPatternDimensionEditorPanel', () => { // Transition to a field operation (incompatible) wrapper - .find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-average incompatible"]') .simulate('click'); // Now check that the dimension gets cleaned up on state update @@ -896,7 +896,7 @@ describe('IndexPatternDimensionEditorPanel', () => { first: { ...state.layers.first, incompleteColumns: { - col2: { operationType: 'avg' }, + col2: { operationType: 'average' }, }, }, }, @@ -914,7 +914,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, })} @@ -1037,7 +1037,7 @@ describe('IndexPatternDimensionEditorPanel', () => { wrapper = shallow( @@ -1143,7 +1143,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Sum of bytes per hour', }); wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(props.setState).toHaveBeenCalledWith( { ...props.state, @@ -1478,7 +1478,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('should support selecting the operation before the field', () => { wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { @@ -1488,7 +1488,7 @@ describe('IndexPatternDimensionEditorPanel', () => { ...state.layers.first, incompleteColumns: { col2: { - operationType: 'avg', + operationType: 'average', }, }, }, @@ -1516,7 +1516,7 @@ describe('IndexPatternDimensionEditorPanel', () => { columns: { ...state.layers.first.columns, col2: expect.objectContaining({ - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }), }, @@ -1547,7 +1547,7 @@ describe('IndexPatternDimensionEditorPanel', () => { /> ); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { @@ -1559,7 +1559,7 @@ describe('IndexPatternDimensionEditorPanel', () => { ...initialState.layers.first.columns, col2: expect.objectContaining({ sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', // Other parts of this don't matter for this test }), }, @@ -1601,7 +1601,7 @@ describe('IndexPatternDimensionEditorPanel', () => { wrapper = mount(); act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); }); const options = wrapper @@ -1790,7 +1790,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }); @@ -1831,7 +1831,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', params: { format: { id: 'bytes', params: { decimals: 0 } }, @@ -1871,7 +1871,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', params: { format: { id: 'bytes', params: { decimals: 2 } }, @@ -1914,7 +1914,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect(wrapper.find('ReferenceEditor')).toHaveLength(0); wrapper - .find('button[data-test-subj="lns-indexPatternDimension-derivative incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-differences incompatible"]') .simulate('click'); expect(wrapper.find('ReferenceEditor')).toHaveLength(1); @@ -1926,7 +1926,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Differences of (incomplete)', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['col2'], params: {}, }, @@ -1939,7 +1939,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect(wrapper.find('ReferenceEditor')).toHaveLength(1); wrapper - .find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-average incompatible"]') .simulate('click'); expect(wrapper.find('ReferenceEditor')).toHaveLength(0); @@ -1948,10 +1948,10 @@ describe('IndexPatternDimensionEditorPanel', () => { it('should show a warning when the current dimension is no longer configurable', () => { const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({ col1: { - label: 'Invalid derivative', + label: 'Invalid differences', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['ref1'], }, }); @@ -1962,7 +1962,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect( wrapper - .find('[data-test-subj="lns-indexPatternDimension-derivative incompatible"]') + .find('[data-test-subj="lns-indexPatternDimension-differences incompatible"]') .find('EuiText[color="danger"]') .first() ).toBeTruthy(); @@ -1975,7 +1975,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Avg', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }), @@ -2010,6 +2010,8 @@ describe('IndexPatternDimensionEditorPanel', () => { ); - expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-derivative"]')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-differences"]')).toHaveLength( + 0 + ); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts deleted file mode 100644 index 82b6434e50aac..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ /dev/null @@ -1,1225 +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 { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { IndexPatternDimensionEditorProps } from './dimension_panel'; -import { onDrop, getDropProps } from './droppable'; -import { DraggingIdentifier } from '../../drag_drop'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; -import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { IndexPatternPrivateState } from '../types'; -import { documentField } from '../document_field'; -import { OperationMetadata, DropType } from '../../types'; -import { IndexPatternColumn, MedianIndexPatternColumn } from '../operations'; -import { getFieldByNameFactory } from '../pure_helpers'; - -const fields = [ - { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'bytes', - displayName: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'memory', - displayName: 'memory', - type: 'number', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'source', - displayName: 'source', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'src', - displayName: 'src', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'dest', - displayName: 'dest', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - documentField, -]; - -const expectedIndexPatterns = { - foo: { - id: 'foo', - title: 'my-fake-index-pattern', - timeFieldName: 'timestamp', - hasExistence: true, - hasRestrictions: false, - fields, - getFieldByName: getFieldByNameFactory(fields), - }, -}; - -const defaultDragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - humanData: { - label: 'Column 2', - }, -}; - -const draggingField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { label: 'Label' }, -}; - -/** - * The datasource exposes four main pieces of code which are tested at - * an integration test level. The main reason for this fairly high level - * of testing is that there is a lot of UI logic that isn't easily - * unit tested, such as the transient invalid state. - * - * - Dimension trigger: Not tested here - * - Dimension editor component: First half of the tests - * - * - getDropProps: Returns drop types that are possible for the current dragging field or other dimension - * - onDrop: Correct application of drop logic - */ -describe('IndexPatternDimensionEditorPanel', () => { - let state: IndexPatternPrivateState; - let setState: jest.Mock; - let defaultProps: IndexPatternDimensionEditorProps; - - beforeEach(() => { - state = { - indexPatternRefs: [], - indexPatterns: expectedIndexPatterns, - currentIndexPatternId: 'foo', - isFirstExistenceFetch: false, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1'], - columns: { - col1: { - label: 'Date histogram of timestamp', - customLabel: true, - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - }, - incompleteColumns: {}, - }, - }, - }; - - setState = jest.fn(); - - defaultProps = { - state, - setState, - dateRange: { fromDate: 'now-1d', toDate: 'now' }, - columnId: 'col1', - layerId: 'first', - uniqueLabel: 'stuff', - groupId: 'group1', - filterOperations: () => true, - storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, - savedObjectsClient: {} as SavedObjectsClientContract, - http: {} as HttpSetup, - data: ({ - fieldFormats: ({ - getType: jest.fn().mockReturnValue({ - id: 'number', - title: 'Number', - }), - getDefaultType: jest.fn().mockReturnValue({ - id: 'bytes', - title: 'Bytes', - }), - } as unknown) as DataPublicPluginStart['fieldFormats'], - } as unknown) as DataPublicPluginStart, - core: {} as CoreSetup, - dimensionGroups: [], - }; - - jest.clearAllMocks(); - }); - - const groupId = 'a'; - - describe('getDropProps', () => { - it('returns undefined if no drag is happening', () => { - const dragging = { - name: 'bar', - id: 'bar', - humanData: { label: 'Label' }, - }; - expect(getDropProps({ ...defaultProps, groupId, dragging })).toBe(undefined); - }); - - it('returns undefined if the dragged item has no field', () => { - const dragging = { - name: 'bar', - id: 'bar', - humanData: { label: 'Label' }, - }; - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging, - }) - ).toBe(undefined); - }); - - it('returns undefined if field is not supported by filterOperations', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - indexPatternId: 'foo', - field: { type: 'string', name: 'mystring', aggregatable: true }, - id: 'mystring', - humanData: { label: 'Label' }, - }, - filterOperations: () => false, - }) - ).toBe(undefined); - }); - - it('returns remove_add if the field is supported by filterOperations and the dropTarget is an existing column', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: draggingField, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toEqual({ dropType: 'field_replace', nextLabel: 'Intervals' }); - }); - - it('returns undefined if the field belongs to another index pattern', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo2', - id: 'bar', - humanData: { label: 'Label' }, - }, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toBe(undefined); - }); - - it('returns undefined if the dragged field is already in use by this operation', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - field: { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, - }, - indexPatternId: 'foo', - id: 'bar', - humanData: { label: 'Label' }, - }, - }) - ).toBe(undefined); - }); - - it('returns move if the dragged column is compatible', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - columnId: 'col2', - }) - ).toEqual({ dropType: 'move_compatible' }); - }); - - it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Date histogram of timestamp (1)', - customLabel: true, - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - }, - }; - - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - - columnId: 'col2', - }) - ).toEqual(undefined); - }); - - it('returns replace_incompatible if dropping column to existing incompatible column', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Sum of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'sum', - sourceField: 'bytes', - }, - }, - }; - - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed === false, - }) - ).toEqual({ dropType: 'replace_incompatible', nextLabel: 'Unique count' }); - }); - }); - describe('onDrop', () => { - it('appends the dropped column when a field is dropped', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - dropType: 'field_replace', - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col1', 'col2'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }, - }, - }, - }); - }); - - it('selects the specific operation that was valid on drop', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed, - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col1', 'col2'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }, - }, - }, - }); - }); - - it('updates a column when a field is dropped', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }), - }), - }, - }); - }); - - it('keeps the operation when dropping a different compatible field', () => { - onDrop({ - ...defaultProps, - droppedItem: { - field: { name: 'memory', type: 'number', aggregatable: true }, - indexPatternId: 'foo', - id: '1', - }, - state: { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1'], - columns: { - col1: { - label: 'Sum of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'sum', - sourceField: 'bytes', - }, - }, - }, - }, - }, - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - operationType: 'sum', - dataType: 'number', - sourceField: 'memory', - }), - }), - }), - }, - }); - }); - - it('updates the column id when moving an operation to an empty dimension', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'bar', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: dragging, - columnId: 'col2', - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col2'], - columns: { - col2: state.layers.first.columns.col1, - }, - }, - }, - }); - }); - - it('replaces an operation when moving to a populated dimension', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Top values of src', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'column', columnId: 'col3' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'src', - }, - col3: { - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', - sourceField: 'Records', - }, - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: defaultDragging, - state: testState, - dropType: 'replace_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col3'], - columns: { - col1: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - }, - }, - }); - }); - - describe('dimension group aware ordering and copying', () => { - let dragging: DraggingIdentifier; - let testState: IndexPatternPrivateState; - beforeEach(() => { - dragging = { - columnId: 'col2', - groupId: 'b', - layerId: 'first', - id: 'col2', - humanData: { - label: '', - }, - }; - testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3', 'col4'], - columns: { - col1: testState.layers.first.columns.col1, - col2: { - label: 'Top values of src', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'src', - }, - col3: { - label: 'Top values of dest', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'dest', - }, - col4: { - label: 'Median of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'median', - sourceField: 'bytes', - }, - }, - }; - }); - const dimensionGroups = [ - { - accessors: [], - groupId: 'a', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - { - accessors: [{ columnId: 'col1' }, { columnId: 'col2' }, { columnId: 'col3' }], - groupId: 'b', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - { - accessors: [{ columnId: 'col4' }], - groupId: 'c', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - ]; - - it('respects groups on moving operations from one group to another', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging col2 into newCol in group a - onDrop({ - ...defaultProps, - columnId: 'newCol', - droppedItem: dragging, - state: testState, - groupId: 'a', - dimensionGroups, - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col3', 'col4'], - columns: { - newCol: testState.layers.first.columns.col2, - col1: testState.layers.first.columns.col1, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('respects groups on moving operations from one group to another with overwrite', () => { - // config: - // a: col1, - // b: col2, col3 - // c: col4 - // dragging col3 onto col1 in group a - const draggingCol3 = { - columnId: 'col3', - groupId: 'b', - layerId: 'first', - id: 'col3', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'col1', - droppedItem: draggingCol3, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'col4'], - columns: { - col1: testState.layers.first.columns.col3, - col2: testState.layers.first.columns.col2, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('moves newly created dimension to the bottom of the current group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // dragging col1 into newCol in group b - const draggingCol1 = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'move_compatible', - droppedItem: draggingCol1, - state: testState, - groupId: 'b', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col2', 'col3', 'newCol', 'col4'], - columns: { - newCol: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('appends the dropped column in the right place when a field is dropped', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging field into newCol in group a - const draggingBytesField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { - label: '', - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: draggingBytesField, - columnId: 'newCol', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: 'a', - dimensionGroups, - dropType: 'field_add', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], - columns: { - newCol: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - incompleteColumns: {}, - }, - }, - }); - }); - - it('appends the dropped column in the right place respecting custom nestingOrder', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging field into newCol in group a - const draggingBytesField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { - label: '', - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: draggingBytesField, - columnId: 'newCol', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: 'a', - dimensionGroups: [ - // a and b are ordered in reverse visually, but nesting order keeps them in place for column order - { ...dimensionGroups[1], nestingOrder: 1 }, - { ...dimensionGroups[0], nestingOrder: 0 }, - { ...dimensionGroups[2] }, - ], - dropType: 'field_add', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], - columns: { - newCol: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - incompleteColumns: {}, - }, - }, - }); - }); - - it('copies column to the bottom of the current group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // copying col1 within group a - const draggingCol1 = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'duplicate_in_group', - droppedItem: draggingCol1, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], - columns: { - col1: testState.layers.first.columns.col1, - newCol: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('moves incompatible column to the bottom of the target group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // dragging col4 into newCol in group a - const draggingCol4 = { - columnId: 'col4', - groupId: 'c', - layerId: 'first', - id: 'col4', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'move_incompatible', - droppedItem: draggingCol4, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'newCol', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - newCol: expect.objectContaining({ - sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) - .sourceField, - }), - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - incompleteColumns: {}, - }, - }, - }); - }); - }); - - it('if dnd is reorder, it correctly reorders columns', () => { - const testState: IndexPatternPrivateState = { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - col2: { - label: 'Top values of bar', - dataType: 'number', - isBucketed: true, - operationType: 'terms', - sourceField: 'bar', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'asc', - size: 5, - }, - }, - col3: { - operationType: 'avg', - sourceField: 'memory', - label: 'average of memory', - dataType: 'number', - isBucketed: false, - }, - }, - }, - }, - }; - - const metricDragging = { - columnId: 'col3', - groupId: 'a', - layerId: 'first', - id: 'col3', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: metricDragging, - state: testState, - dropType: 'duplicate_in_group', - columnId: 'newCol', - }); - // metric is appended - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'col3', 'newCol'], - columns: { - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - newCol: testState.layers.first.columns.col3, - }, - }, - }, - }); - - const bucketDragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: bucketDragging, - state: testState, - dropType: 'duplicate_in_group', - columnId: 'newCol', - }); - - // bucket is placed after the last existing bucket - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'newCol', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - newCol: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - }, - }, - }); - }); - - it('if dropType is reorder, it correctly reorders columns', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }; - const testState = { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - } as IndexPatternColumn, - col2: { - label: 'Top values of bar', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, - col3: { - label: 'Top values of memory', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, - }, - }, - }, - }; - - const defaultReorderDropParams = { - ...defaultProps, - dragging, - droppedItem: dragging, - state: testState, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - dropType: 'reorder' as DropType, - }; - - const stateWithColumnOrder = (columnOrder: string[]) => { - return { - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder, - columns: { - ...testState.layers.first.columns, - }, - }, - }, - }; - }; - - // first element to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); - - // last element to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { - columnId: 'col3', - groupId: 'a', - layerId: 'first', - id: 'col3', - }, - }); - expect(setState).toBeCalledTimes(2); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); - - // middle column to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - }, - }); - expect(setState).toBeCalledTimes(3); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); - - // middle column to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - droppedItem: { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - }, - }); - expect(setState).toBeCalledTimes(4); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts deleted file mode 100644 index e846db718f1d3..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - DatasourceDimensionDropProps, - DatasourceDimensionDropHandlerProps, - isDraggedOperation, - DraggedOperation, - DropType, -} from '../../types'; -import { IndexPatternColumn } from '../indexpattern'; -import { - insertOrReplaceColumn, - deleteColumn, - getOperationTypesForField, - getColumnOrder, - reorderByGroups, - getOperationDisplay, -} from '../operations'; -import { mergeLayer } from '../state_helpers'; -import { hasField, isDraggedField } from '../utils'; -import { IndexPatternPrivateState, DraggedField } from '../types'; -import { trackUiEvent } from '../../lens_ui_telemetry'; -import { DragContextState } from '../../drag_drop/providers'; - -type DropHandlerProps = DatasourceDimensionDropHandlerProps & { - droppedItem: T; -}; - -const operationLabels = getOperationDisplay(); - -export function getDropProps( - props: DatasourceDimensionDropProps & { - dragging: DragContextState['dragging']; - groupId: string; - } -): { dropType: DropType; nextLabel?: string } | undefined { - const { dragging } = props; - if (!dragging) { - return; - } - - const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; - - const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; - if (isDraggedField(dragging)) { - const operationsForNewField = getOperationTypesForField(dragging.field, props.filterOperations); - - if (!!(layerIndexPatternId === dragging.indexPatternId && operationsForNewField.length)) { - const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; - if (!currentColumn) { - return { dropType: 'field_add', nextLabel: highestPriorityOperationLabel }; - } else if ( - (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name) || - !hasField(currentColumn) - ) { - const persistingOperationLabel = - currentColumn && - operationsForNewField.includes(currentColumn.operationType) && - operationLabels[currentColumn.operationType].displayName; - - return { - dropType: 'field_replace', - nextLabel: persistingOperationLabel || highestPriorityOperationLabel, - }; - } - } - return; - } - - if ( - isDraggedOperation(dragging) && - dragging.layerId === props.layerId && - props.columnId !== dragging.columnId - ) { - // same group - if (props.groupId === dragging.groupId) { - if (currentColumn) { - return { dropType: 'reorder' }; - } - return { dropType: 'duplicate_in_group' }; - } - - // compatible group - const op = props.state.layers[dragging.layerId].columns[dragging.columnId]; - if ( - !op || - (currentColumn && - hasField(currentColumn) && - hasField(op) && - currentColumn.sourceField === op.sourceField) - ) { - return; - } - if (props.filterOperations(op)) { - if (currentColumn) { - return { dropType: 'replace_compatible' }; // in the future also 'swap_compatible' and 'duplicate_compatible' - } else { - return { dropType: 'move_compatible' }; // in the future also 'duplicate_compatible' - } - } - - // suggest - const field = - hasField(op) && props.state.indexPatterns[layerIndexPatternId].getFieldByName(op.sourceField); - const operationsForNewField = field && getOperationTypesForField(field, props.filterOperations); - - if (operationsForNewField && operationsForNewField?.length) { - const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; - - if (currentColumn) { - const persistingOperationLabel = - currentColumn && - operationsForNewField.includes(currentColumn.operationType) && - operationLabels[currentColumn.operationType].displayName; - return { - dropType: 'replace_incompatible', - nextLabel: persistingOperationLabel || highestPriorityOperationLabel, - }; // in the future also 'swap_incompatible', 'duplicate_incompatible' - } else { - return { - dropType: 'move_incompatible', - nextLabel: highestPriorityOperationLabel, - }; // in the future also 'duplicate_incompatible' - } - } - } -} - -export function onDrop(props: DatasourceDimensionDropHandlerProps) { - const { droppedItem, dropType } = props; - - if (dropType === 'field_add' || dropType === 'field_replace') { - return operationOnDropMap[dropType]({ - ...props, - droppedItem: droppedItem as DraggedField, - }); - } - return operationOnDropMap[dropType]({ - ...props, - droppedItem: droppedItem as DraggedOperation, - }); -} - -const operationOnDropMap = { - field_add: onFieldDrop, - field_replace: onFieldDrop, - reorder: onReorderDrop, - duplicate_in_group: onSameGroupDuplicateDrop, - move_compatible: onMoveDropToCompatibleGroup, - replace_compatible: onMoveDropToCompatibleGroup, - move_incompatible: onMoveDropToNonCompatibleGroup, - replace_incompatible: onMoveDropToNonCompatibleGroup, -}; - -function reorderElements(items: string[], dest: string, src: string) { - const result = items.filter((c) => c !== src); - const destIndex = items.findIndex((c) => c === src); - const destPosition = result.indexOf(dest); - - const srcIndex = items.findIndex((c) => c === dest); - - result.splice(destIndex < srcIndex ? destPosition + 1 : destPosition, 0, src); - return result; -} - -function onReorderDrop({ - columnId, - setState, - state, - layerId, - droppedItem, -}: DropHandlerProps) { - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: reorderElements( - state.layers[layerId].columnOrder, - columnId, - droppedItem.columnId - ), - }, - }) - ); - - return true; -} - -function onMoveDropToNonCompatibleGroup(props: DropHandlerProps) { - const { columnId, setState, state, layerId, droppedItem, dimensionGroups, groupId } = props; - - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - const field = - hasField(op) && state.indexPatterns[layer.indexPatternId].getFieldByName(op.sourceField); - if (!field) { - return false; - } - - const operationsForNewField = getOperationTypesForField(field, props.filterOperations); - - if (!operationsForNewField.length) { - return false; - } - - const currentIndexPattern = state.indexPatterns[layer.indexPatternId]; - // Detects if we can change the field only, otherwise change field + operation - - const selectedColumn: IndexPatternColumn | null = layer.columns[columnId] || null; - - const fieldIsCompatibleWithCurrent = - selectedColumn && operationsForNewField.includes(selectedColumn.operationType); - - const newLayer = insertOrReplaceColumn({ - layer: deleteColumn({ - layer, - columnId: droppedItem.columnId, - indexPattern: currentIndexPattern, - }), - columnId, - indexPattern: currentIndexPattern, - op: fieldIsCompatibleWithCurrent ? selectedColumn.operationType : operationsForNewField[0], - field, - visualizationGroups: dimensionGroups, - targetGroup: groupId, - }); - - trackUiEvent('drop_onto_dimension'); - setState( - mergeLayer({ - state, - layerId, - newLayer: { - ...newLayer, - }, - }) - ); - - return { deleted: droppedItem.columnId }; -} - -function onSameGroupDuplicateDrop({ - columnId, - setState, - state, - layerId, - droppedItem, - dimensionGroups, - groupId, -}: DropHandlerProps) { - const layer = state.layers[layerId]; - - const op = { ...layer.columns[droppedItem.columnId] }; - const newColumns = { - ...layer.columns, - [columnId]: op, - }; - - const newColumnOrder = [...layer.columnOrder]; - // put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array - // then reorder based on dimension groups if necessary - const insertionIndex = op.isBucketed - ? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed) - : newColumnOrder.length; - newColumnOrder.splice(insertionIndex, 0, columnId); - - const newLayer = { - ...layer, - columnOrder: newColumnOrder, - columns: newColumns, - }; - - const updatedColumnOrder = getColumnOrder(newLayer); - - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: updatedColumnOrder, - columns: newColumns, - }, - }) - ); - return true; -} - -function onMoveDropToCompatibleGroup({ - columnId, - setState, - state, - layerId, - droppedItem, - dimensionGroups, - groupId, -}: DropHandlerProps) { - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - const newColumns = { ...layer.columns }; - delete newColumns[droppedItem.columnId]; - newColumns[columnId] = op; - - const newColumnOrder = [...layer.columnOrder]; - const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); - const newIndex = newColumnOrder.findIndex((c) => c === columnId); - - if (newIndex === -1) { - // for newly created columns, remove the old entry and add the last one to the end - newColumnOrder.splice(oldIndex, 1); - newColumnOrder.push(columnId); - } else { - // for drop to replace, reuse the same index - newColumnOrder[oldIndex] = columnId; - } - const newLayer = { - ...layer, - columnOrder: newColumnOrder, - columns: newColumns, - }; - - const updatedColumnOrder = getColumnOrder(newLayer); - - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - - newLayer: { - columnOrder: updatedColumnOrder, - columns: newColumns, - }, - }) - ); - return { deleted: droppedItem.columnId }; -} - -function onFieldDrop(props: DropHandlerProps) { - const { columnId, setState, state, layerId, droppedItem, groupId, dimensionGroups } = props; - - const operationsForNewField = getOperationTypesForField( - droppedItem.field, - props.filterOperations - ); - - if (!isDraggedField(droppedItem) || !operationsForNewField.length) { - // TODO: What do we do if we couldn't find a column? - return false; - } - - const layer = state.layers[layerId]; - - const selectedColumn: IndexPatternColumn | null = layer.columns[columnId] || null; - const currentIndexPattern = state.indexPatterns[layer.indexPatternId]; - - // Detects if we can change the field only, otherwise change field + operation - const fieldIsCompatibleWithCurrent = - selectedColumn && operationsForNewField.includes(selectedColumn.operationType); - - const newLayer = insertOrReplaceColumn({ - layer, - columnId, - indexPattern: currentIndexPattern, - op: fieldIsCompatibleWithCurrent ? selectedColumn.operationType : operationsForNewField[0], - field: droppedItem.field, - visualizationGroups: dimensionGroups, - targetGroup: groupId, - }); - - trackUiEvent('drop_onto_dimension'); - const hasData = Object.values(state.layers).some(({ columns }) => columns.length); - trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); - setState(mergeLayer({ state, layerId, newLayer })); - return true; -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts new file mode 100644 index 0000000000000..051feb331aec4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -0,0 +1,1556 @@ +/* + * Copyright 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 { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; +import { IndexPatternDimensionEditorProps } from '../dimension_panel'; +import { onDrop } from './on_drop_handler'; +import { getDropProps } from './get_drop_props'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { IndexPatternLayer, IndexPatternPrivateState } from '../../types'; +import { documentField } from '../../document_field'; +import { OperationMetadata, DropType } from '../../../types'; +import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations'; +import { getFieldByNameFactory } from '../../pure_helpers'; + +const fields = [ + { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + displayName: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + displayName: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + displayName: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'src', + displayName: 'src', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'dest', + displayName: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, +]; + +const expectedIndexPatterns = { + foo: { + id: 'foo', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + hasRestrictions: false, + fields, + getFieldByName: getFieldByNameFactory(fields), + }, +}; + +const dimensionGroups = [ + { + accessors: [], + groupId: 'a', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed, + }, + { + accessors: [{ columnId: 'col1' }, { columnId: 'col2' }, { columnId: 'col3' }], + groupId: 'b', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed, + }, + { + accessors: [{ columnId: 'col4' }], + groupId: 'c', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }, +]; + +const oneColumnLayer: IndexPatternLayer = { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + incompleteColumns: {}, +}; + +const multipleColumnsLayer: IndexPatternLayer = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: oneColumnLayer.columns.col1, + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Top values of dest', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'dest', + }, + col4: { + label: 'Median of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'median', + sourceField: 'bytes', + }, + }, +}; + +const draggingField = { + field: { type: 'number', name: 'bytes', aggregatable: true }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, +}; + +const draggingCol1 = { + columnId: 'col1', + groupId: 'a', + layerId: 'first', + id: 'col1', + humanData: { label: 'Column 1' }, +}; + +const draggingCol2 = { + columnId: 'col2', + groupId: 'b', + layerId: 'first', + id: 'col2', + humanData: { label: 'Column 2' }, + filterOperations: (op: OperationMetadata) => op.isBucketed, +}; + +const draggingCol3 = { + columnId: 'col3', + groupId: 'b', + layerId: 'first', + id: 'col3', + humanData: { + label: '', + }, +}; + +const draggingCol4 = { + columnId: 'col4', + groupId: 'c', + layerId: 'first', + id: 'col4', + humanData: { + label: '', + }, + filterOperations: (op: OperationMetadata) => op.isBucketed === false, +}; + +/** + * The datasource exposes four main pieces of code which are tested at + * an integration test level. The main reason for this fairly high level + * of testing is that there is a lot of UI logic that isn't easily + * unit tested, such as the transient invalid state. + * + * - Dimension trigger: Not tested here + * - Dimension editor component: First half of the tests + * + * - getDropProps: Returns drop types that are possible for the current dragging field or other dimension + * - onDrop: Correct application of drop logic + */ +describe('IndexPatternDimensionEditorPanel', () => { + let state: IndexPatternPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: 'foo', + isFirstExistenceFetch: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { first: { ...oneColumnLayer } }, + }; + + setState = jest.fn(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + groupId: 'group1', + filterOperations: () => true, + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, + core: {} as CoreSetup, + dimensionGroups: [], + }; + + jest.clearAllMocks(); + }); + + const groupId = 'a'; + + describe('getDropProps', () => { + it('returns undefined if no drag is happening', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + }) + ).toBe(undefined); + }); + + it('returns undefined if the dragged item has no field', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }, + }) + ).toBe(undefined); + }); + + describe('dragging a field', () => { + it('returns undefined if field is not supported by filterOperations', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: draggingField, + filterOperations: () => false, + }) + ).toBe(undefined); + }); + + it('returns field_replace if the field is supported by filterOperations and the dropTarget is an existing column', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toEqual({ dropTypes: ['field_replace'], nextLabel: 'Intervals' }); + }); + + it('returns field_add if the field is supported by filterOperations and the dropTarget is an empty column', () => { + expect( + getDropProps({ + ...defaultProps, + columnId: 'newId', + groupId, + dragging: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toEqual({ dropTypes: ['field_add'], nextLabel: 'Intervals' }); + }); + + it('returns undefined if the field belongs to another index pattern', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + id: 'bar', + humanData: { label: 'Label' }, + }, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toBe(undefined); + }); + + it('returns undefined if the dragged field is already in use by this operation', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + field: { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, + }, + }) + ).toBe(undefined); + }); + }); + + describe('dragging a column', () => { + it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Date histogram of timestamp (1)', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual(undefined); + }); + + it('returns reorder if drop target and droppedItem columns are from the same group and both are existing', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { ...draggingCol1, groupId }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: ['reorder'], + }); + }); + + it('returns duplicate_compatible if drop target and droppedItem columns are from the same group and drop target id is a new column', () => { + expect( + getDropProps({ + ...defaultProps, + columnId: 'newId', + groupId, + dragging: { + ...draggingCol1, + groupId, + }, + }) + ).toEqual({ dropTypes: ['duplicate_compatible'] }); + }); + + it('returns compatible drop types if the dragged column is compatible', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual({ dropTypes: ['move_compatible', 'duplicate_compatible'] }); + }); + + it('returns incompatible drop target types if dropping column to existing incompatible column', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: [ + 'replace_incompatible', + 'replace_duplicate_incompatible', + 'swap_incompatible', + ], + nextLabel: 'Unique count', + }); + }); + + it('does not return swap_incompatible if current dropTarget column cannot be swapped to the group of dragging column', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + filterOperations: (op: OperationMetadata) => op.isBucketed === true, + }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: ['replace_incompatible', 'replace_duplicate_incompatible'], + nextLabel: 'Unique count', + }); + }); + }); + }); + + describe('onDrop', () => { + describe('dropping a field', () => { + it('updates a column when a field is dropped', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }), + }), + }, + }); + }); + it('selects the specific operation that was valid on drop', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }, + }, + }, + }); + }); + it('keeps the operation when dropping a different compatible field', () => { + onDrop({ + ...defaultProps, + droppedItem: { + field: { name: 'memory', type: 'number', aggregatable: true }, + indexPatternId: 'foo', + id: '1', + }, + state: { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }, + }, + }, + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + operationType: 'sum', + dataType: 'number', + sourceField: 'memory', + }), + }), + }), + }, + }); + }); + it('appends the dropped column when a field is dropped', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + dropType: 'field_replace', + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }, + }, + }, + }); + }); + it('dimensionGroups are defined - appends the dropped column in the right place when a field is dropped', () => { + const testState = { ...state }; + testState.layers.first = { ...multipleColumnsLayer }; + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging field into newCol in group a + + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'newCol', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', + dimensionGroups, + dropType: 'field_add', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + }); + + describe('dropping a dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + }; + + it('sets correct order in group for metric and bucket columns when duplicating a column in group', () => { + const testState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + col2: { + label: 'Top values of bar', + dataType: 'number', + isBucketed: true, + operationType: 'terms', + sourceField: 'bar', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }, + col3: { + operationType: 'average', + sourceField: 'memory', + label: 'average of memory', + dataType: 'number', + isBucketed: false, + }, + }, + }, + }, + }; + + const metricDragging = { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + droppedItem: metricDragging, + state: testState, + dropType: 'duplicate_compatible', + columnId: 'newCol', + }); + // metric is appended + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'newCol'], + columns: { + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + newCol: testState.layers.first.columns.col3, + }, + }, + }, + }); + + const bucketDragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + droppedItem: bucketDragging, + state: testState, + dropType: 'duplicate_compatible', + columnId: 'newCol', + }); + + // bucket is placed after the last existing bucket + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'newCol', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + newCol: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + }, + }, + }); + }); + + it('sets correct order in group when reordering a column in group', () => { + const testState = { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + } as IndexPatternColumn, + col2: { + label: 'Top values of bar', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + col3: { + label: 'Top values of memory', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + }, + }, + }, + }; + + const defaultReorderDropParams = { + ...defaultProps, + dragging, + droppedItem: draggingCol1, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + dropType: 'reorder' as DropType, + }; + + const stateWithColumnOrder = (columnOrder: string[]) => { + return { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder, + columns: { + ...testState.layers.first.columns, + }, + }, + }, + }; + }; + + // first element to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + }); + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); + + // last element to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + }, + }); + expect(setState).toBeCalledTimes(2); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); + + // middle column to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(3); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); + + // middle column to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(4); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); + }); + + it('updates the column id when moving an operation to an empty dimension', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingCol1, + columnId: 'col2', + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col2'], + columns: { + col2: state.layers.first.columns.col1, + }, + }, + }, + }); + }); + + it('replaces an operation when moving to a populated dimension', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', + }, + }, + }; + + onDrop({ + ...defaultProps, + droppedItem: draggingCol2, + state: testState, + dropType: 'replace_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + }, + }, + }); + }); + + describe('dimension group aware ordering and copying', () => { + let testState: IndexPatternPrivateState; + beforeEach(() => { + testState = { ...state }; + testState.layers.first = { ...multipleColumnsLayer }; + }); + + it('respects groups on moving operations between compatible groups', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging col2 into newCol in group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + droppedItem: draggingCol2, + state: testState, + groupId: 'a', + dimensionGroups, + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col3', 'col4'], + columns: { + newCol: testState.layers.first.columns.col2, + col1: testState.layers.first.columns.col1, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on duplicating operations between compatible groups', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging col2 into newCol in group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + droppedItem: draggingCol2, + state: testState, + groupId: 'a', + dimensionGroups, + dropType: 'duplicate_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: testState.layers.first.columns.col2, + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on moving operations between compatible groups with overwrite', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // dragging col3 onto col1 in group a + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col4'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on duplicating operations between compatible groups with overwrite', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // dragging col3 onto col1 in group a + + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'duplicate_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('moves newly created dimension to the bottom of the current group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col1 into newCol in group b + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'move_compatible', + droppedItem: draggingCol1, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col2', 'col3', 'newCol', 'col4'], + columns: { + newCol: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('copies column to the bottom of the current group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // copying col1 within group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'duplicate_compatible', + droppedItem: draggingCol1, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('appends the dropped column in the right place respecting custom nestingOrder', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging field into newCol in group a + + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'newCol', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', + dimensionGroups: [ + // a and b are ordered in reverse visually, but nesting order keeps them in place for column order + { ...dimensionGroups[1], nestingOrder: 1 }, + { ...dimensionGroups[0], nestingOrder: 0 }, + { ...dimensionGroups[2] }, + ], + dropType: 'field_add', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('moves incompatible column to the bottom of the target group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into newCol in group a + + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'move_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: expect.objectContaining({ + sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) + .sourceField, + }), + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('copies incompatible column to the bottom of the target group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into newCol in group a + + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'duplicate_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: expect.objectContaining({ + sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) + .sourceField, + }), + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('moves incompatible column with overwrite keeping order of target column', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col2 in group b + + onDrop({ + ...defaultProps, + columnId: 'col2', + dropType: 'move_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + col2: { + isBucketed: true, + label: 'Top values of bytes', + operationType: 'terms', + sourceField: 'bytes', + dataType: 'number', + params: { + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'desc', + size: 10, + }, + }, + col3: testState.layers.first.columns.col3, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('when swapping compatibly, columns carry order', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col1 + + onDrop({ + ...defaultProps, + columnId: 'col1', + dropType: 'swap_compatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col4, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col1, + }, + }, + }, + }); + }); + + it('when swapping incompatibly, newly created columns take order from the columns they replace', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col2 + + onDrop({ + ...defaultProps, + columnId: 'col2', + dropType: 'swap_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + col2: { + isBucketed: true, + label: 'Top values of bytes', + operationType: 'terms', + sourceField: 'bytes', + dataType: 'number', + params: { + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'desc', + size: 10, + }, + }, + col3: testState.layers.first.columns.col3, + col4: { + isBucketed: false, + label: 'Unique count of src', + filter: undefined, + operationType: 'unique_count', + sourceField: 'src', + dataType: 'number', + params: undefined, + scale: 'ratio', + }, + }, + incompleteColumns: {}, + }, + }, + }); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts new file mode 100644 index 0000000000000..a98a29aea6682 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + DatasourceDimensionDropProps, + isDraggedOperation, + DraggedOperation, + DropType, +} from '../../../types'; +import { getOperationDisplay } from '../../operations'; +import { hasField, isDraggedField } from '../../utils'; +import { DragContextState } from '../../../drag_drop/providers'; +import { OperationMetadata } from '../../../types'; +import { getOperationTypesForField } from '../../operations'; +import { IndexPatternColumn } from '../../indexpattern'; +import { + IndexPatternPrivateState, + IndexPattern, + IndexPatternField, + DraggedField, +} from '../../types'; + +type GetDropProps = DatasourceDimensionDropProps & { + dragging?: DragContextState['dragging']; + groupId: string; +}; + +type DropProps = { dropTypes: DropType[]; nextLabel?: string } | undefined; + +const operationLabels = getOperationDisplay(); + +export function getNewOperation( + field: IndexPatternField | undefined | false, + filterOperations: (meta: OperationMetadata) => boolean, + targetColumn: IndexPatternColumn +) { + if (!field) { + return; + } + const newOperations = getOperationTypesForField(field, filterOperations); + if (!newOperations.length) { + return; + } + // Detects if we can change the field only, otherwise change field + operation + const shouldOperationPersist = targetColumn && newOperations.includes(targetColumn.operationType); + return shouldOperationPersist ? targetColumn.operationType : newOperations[0]; +} + +export function getField(column: IndexPatternColumn | undefined, indexPattern: IndexPattern) { + if (!column) { + return; + } + const field = (hasField(column) && indexPattern.getFieldByName(column.sourceField)) || undefined; + return field; +} + +export function getDropProps(props: GetDropProps) { + const { state, columnId, layerId, dragging, groupId, filterOperations } = props; + if (!dragging) { + return; + } + + if (isDraggedField(dragging)) { + return getDropPropsForField({ ...props, dragging }); + } + + if ( + isDraggedOperation(dragging) && + dragging.layerId === layerId && + columnId !== dragging.columnId + ) { + const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; + const targetColumn = state.layers[layerId].columns[columnId]; + + const isSameGroup = groupId === dragging.groupId; + if (isSameGroup) { + return getDropPropsForSameGroup(targetColumn); + } else if (hasTheSameField(sourceColumn, targetColumn)) { + return; + } else if (filterOperations(sourceColumn)) { + return getDropPropsForCompatibleGroup(targetColumn); + } else { + return getDropPropsFromIncompatibleGroup({ ...props, dragging }); + } + } +} + +function hasTheSameField(sourceColumn: IndexPatternColumn, targetColumn?: IndexPatternColumn) { + return ( + targetColumn && + hasField(targetColumn) && + hasField(sourceColumn) && + targetColumn.sourceField === sourceColumn.sourceField + ); +} + +function getDropPropsForField({ + state, + columnId, + layerId, + dragging, + filterOperations, +}: GetDropProps & { dragging: DraggedField }): DropProps { + const targetColumn = state.layers[layerId].columns[columnId]; + const isTheSameIndexPattern = state.layers[layerId].indexPatternId === dragging.indexPatternId; + const newOperation = getNewOperation(dragging.field, filterOperations, targetColumn); + + if (!!(isTheSameIndexPattern && newOperation)) { + const nextLabel = operationLabels[newOperation].displayName; + + if (!targetColumn) { + return { dropTypes: ['field_add'], nextLabel }; + } else if ( + (hasField(targetColumn) && targetColumn.sourceField !== dragging.field.name) || + !hasField(targetColumn) + ) { + return { + dropTypes: ['field_replace'], + nextLabel, + }; + } + } + return; +} + +function getDropPropsForSameGroup(targetColumn?: IndexPatternColumn): DropProps { + return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +} + +function getDropPropsForCompatibleGroup(targetColumn?: IndexPatternColumn): DropProps { + return { + dropTypes: targetColumn + ? ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'] + : ['move_compatible', 'duplicate_compatible'], + }; +} + +function getDropPropsFromIncompatibleGroup({ + state, + columnId, + layerId, + dragging, + filterOperations, +}: GetDropProps & { dragging: DraggedOperation }): DropProps { + const targetColumn = state.layers[layerId].columns[columnId]; + const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; + + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + const sourceField = getField(sourceColumn, layerIndexPattern); + const newOperationForSource = getNewOperation(sourceField, filterOperations, targetColumn); + + if (newOperationForSource) { + const targetField = getField(targetColumn, layerIndexPattern); + const canSwap = !!getNewOperation(targetField, dragging.filterOperations, sourceColumn); + + return { + dropTypes: targetColumn + ? canSwap + ? ['replace_incompatible', 'replace_duplicate_incompatible', 'swap_incompatible'] + : ['replace_incompatible', 'replace_duplicate_incompatible'] + : ['move_incompatible', 'duplicate_incompatible'], + nextLabel: operationLabels[newOperationForSource].displayName, + }; + } +} diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts similarity index 69% rename from x-pack/plugins/data_enhanced/public/autocomplete/index.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts index 7910ce3ffb237..07adce49eb90a 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts @@ -5,7 +5,5 @@ * 2.0. */ -export { - setupKqlQuerySuggestionProvider, - KUERY_LANGUAGE_NAME, -} from './providers/kql_query_suggestion'; +export { onDrop } from './on_drop_handler'; +export { getDropProps } from './get_drop_props'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts new file mode 100644 index 0000000000000..17b5cbc661ca3 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -0,0 +1,358 @@ +/* + * Copyright 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 { DatasourceDimensionDropHandlerProps, DraggedOperation } from '../../../types'; +import { + insertOrReplaceColumn, + deleteColumn, + getColumnOrder, + reorderByGroups, +} from '../../operations'; +import { mergeLayer } from '../../state_helpers'; +import { isDraggedField } from '../../utils'; +import { getNewOperation, getField } from './get_drop_props'; +import { IndexPatternPrivateState, DraggedField } from '../../types'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; + +type DropHandlerProps = DatasourceDimensionDropHandlerProps & { + droppedItem: T; +}; + +export function onDrop(props: DatasourceDimensionDropHandlerProps) { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add' || dropType === 'field_replace') { + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedField, + }); + } + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedOperation, + }); +} + +const operationOnDropMap = { + field_add: onFieldDrop, + field_replace: onFieldDrop, + + reorder: onReorder, + + move_compatible: (props: DropHandlerProps) => onMoveCompatible(props, true), + replace_compatible: (props: DropHandlerProps) => onMoveCompatible(props, true), + duplicate_compatible: onMoveCompatible, + replace_duplicate_compatible: onMoveCompatible, + + move_incompatible: (props: DropHandlerProps) => onMoveIncompatible(props, true), + replace_incompatible: (props: DropHandlerProps) => + onMoveIncompatible(props, true), + duplicate_incompatible: onMoveIncompatible, + replace_duplicate_incompatible: onMoveIncompatible, + + swap_compatible: onSwapCompatible, + swap_incompatible: onSwapIncompatible, +}; + +function onFieldDrop(props: DropHandlerProps) { + const { + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + groupId, + dimensionGroups, + } = props; + + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const targetColumn = layer.columns[columnId]; + const newOperation = getNewOperation(droppedItem.field, filterOperations, targetColumn); + + if (!isDraggedField(droppedItem) || !newOperation) { + return false; + } + + const newLayer = insertOrReplaceColumn({ + layer, + columnId, + indexPattern, + op: newOperation, + field: droppedItem.field, + visualizationGroups: dimensionGroups, + targetGroup: groupId, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + setState(mergeLayer({ state, layerId, newLayer })); + return true; +} + +function onMoveCompatible( + { + columnId, + setState, + state, + layerId, + droppedItem, + dimensionGroups, + groupId, + }: DropHandlerProps, + shouldDeleteSource?: boolean +) { + const layer = state.layers[layerId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + + const newColumns = { + ...layer.columns, + [columnId]: { ...sourceColumn }, + }; + if (shouldDeleteSource) { + delete newColumns[droppedItem.columnId]; + } + + const newColumnOrder = [...layer.columnOrder]; + + if (shouldDeleteSource) { + const sourceIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const targetIndex = newColumnOrder.findIndex((c) => c === columnId); + + if (targetIndex === -1) { + // for newly created columns, remove the old entry and add the last one to the end + newColumnOrder.splice(sourceIndex, 1); + newColumnOrder.push(columnId); + } else { + // for drop to replace, reuse the same index + newColumnOrder[sourceIndex] = columnId; + } + } else { + // put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array + // then reorder based on dimension groups if necessary + const insertionIndex = sourceColumn.isBucketed + ? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed) + : newColumnOrder.length; + newColumnOrder.splice(insertionIndex, 0, columnId); + } + + const newLayer = { + ...layer, + columnOrder: newColumnOrder, + columns: newColumns, + }; + + const updatedColumnOrder = getColumnOrder(newLayer); + + reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: updatedColumnOrder, + columns: newColumns, + }, + }) + ); + return shouldDeleteSource ? { deleted: droppedItem.columnId } : true; +} + +function onReorder({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) { + function reorderElements(items: string[], dest: string, src: string) { + const result = items.filter((c) => c !== src); + const targetIndex = items.findIndex((c) => c === src); + const sourceIndex = items.findIndex((c) => c === dest); + + const targetPosition = result.indexOf(dest); + result.splice(targetIndex < sourceIndex ? targetPosition + 1 : targetPosition, 0, src); + return result; + } + + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: reorderElements( + state.layers[layerId].columnOrder, + columnId, + droppedItem.columnId + ), + }, + }) + ); + return true; +} + +function onMoveIncompatible( + { + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + dimensionGroups, + groupId, + }: DropHandlerProps, + shouldDeleteSource?: boolean +) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + const targetColumn = layer.columns[columnId] || null; + + const sourceField = getField(sourceColumn, indexPattern); + const newOperation = getNewOperation(sourceField, filterOperations, targetColumn); + if (!newOperation) { + return false; + } + + const modifiedLayer = shouldDeleteSource + ? deleteColumn({ + layer, + columnId: droppedItem.columnId, + indexPattern, + }) + : layer; + + const newLayer = insertOrReplaceColumn({ + layer: modifiedLayer, + columnId, + indexPattern, + op: newOperation, + field: sourceField, + visualizationGroups: dimensionGroups, + targetGroup: groupId, + }); + + trackUiEvent('drop_onto_dimension'); + setState( + mergeLayer({ + state, + layerId, + newLayer, + }) + ); + return shouldDeleteSource ? { deleted: droppedItem.columnId } : true; +} + +function onSwapIncompatible({ + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + dimensionGroups, + groupId, +}: DropHandlerProps) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + const targetColumn = layer.columns[columnId]; + + const sourceField = getField(sourceColumn, indexPattern); + const targetField = getField(targetColumn, indexPattern); + + const newOperationForSource = getNewOperation(sourceField, filterOperations, targetColumn); + const newOperationForTarget = getNewOperation( + targetField, + droppedItem.filterOperations, + sourceColumn + ); + + if (!newOperationForSource || !newOperationForTarget) { + return false; + } + + const newLayer = insertOrReplaceColumn({ + layer: insertOrReplaceColumn({ + layer, + columnId, + targetGroup: groupId, + indexPattern, + op: newOperationForSource, + field: sourceField, + visualizationGroups: dimensionGroups, + }), + columnId: droppedItem.columnId, + indexPattern, + op: newOperationForTarget, + field: targetField, + visualizationGroups: dimensionGroups, + targetGroup: droppedItem.groupId, + }); + + trackUiEvent('drop_onto_dimension'); + setState( + mergeLayer({ + state, + layerId, + newLayer, + }) + ); + return true; +} + +const swapColumnOrder = (columnOrder: string[], sourceId: string, targetId: string) => { + const newColumnOrder = [...columnOrder]; + const sourceIndex = newColumnOrder.findIndex((c) => c === sourceId); + const targetIndex = newColumnOrder.findIndex((c) => c === targetId); + + newColumnOrder[sourceIndex] = targetId; + newColumnOrder[targetIndex] = sourceId; + + return newColumnOrder; +}; + +function onSwapCompatible({ + columnId, + setState, + state, + layerId, + droppedItem, + dimensionGroups, + groupId, +}: DropHandlerProps) { + const layer = state.layers[layerId]; + const sourceId = droppedItem.columnId; + const targetId = columnId; + + const sourceColumn = { ...layer.columns[sourceId] }; + const targetColumn = { ...layer.columns[targetId] }; + const newColumns = { ...layer.columns }; + newColumns[targetId] = sourceColumn; + newColumns[sourceId] = targetColumn; + + const updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); + reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: updatedColumnOrder, + columns: newColumns, + }, + }) + ); + + return true; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index f44004e14d580..ffd4ac2498133 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -32,7 +32,7 @@ export interface FieldChoice { operationType: OperationType; } -export interface FieldSelectProps extends EuiComboBoxProps<{}> { +export interface FieldSelectProps extends EuiComboBoxProps { currentIndexPattern: IndexPattern; selectedOperationType?: OperationType; selectedField?: string; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx index 9ad6a2d20a4c2..f17adf9be39f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx @@ -157,7 +157,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -192,7 +192,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -233,7 +233,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -276,7 +276,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -296,9 +296,9 @@ describe('reference editor', () => { expect(subFunctionSelect.prop('selectedOptions')).toEqual( expect.arrayContaining([ expect.objectContaining({ - 'data-test-subj': 'lns-indexPatternDimension-avg incompatible', + 'data-test-subj': 'lns-indexPatternDimension-average incompatible', label: 'Average', - value: 'avg', + value: 'average', }), ]) ); @@ -334,7 +334,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -352,7 +352,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true); expect(fieldSelect.prop('selectedField')).toEqual('bytes'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toEqual('max'); expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false); }); @@ -369,7 +369,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -387,7 +387,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(false); expect(fieldSelect.prop('selectedField')).toEqual('timestamp'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toBeUndefined(); }); @@ -423,7 +423,7 @@ describe('reference editor', () => { label: 'Average of missing', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'missing', }, }, @@ -438,7 +438,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true); expect(fieldSelect.prop('selectedField')).toEqual('missing'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toBeUndefined(); expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index c02515c2e7201..14834adfc33cc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -546,7 +546,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', timeScale: 'h', }, col3: { @@ -659,7 +659,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', timeScale: 'h', }, col3: { @@ -835,7 +835,7 @@ describe('IndexPattern Data Source', () => { dataType: 'date', isBucketed: false, sourceField: 'timefield', - operationType: 'cardinality', + operationType: 'unique_count', }, col2: { label: 'Date', @@ -885,7 +885,7 @@ describe('IndexPattern Data Source', () => { dataType: 'date', isBucketed: false, sourceField: 'timefield', - operationType: 'cardinality', + operationType: 'unique_count', }, col2: { label: 'Reference', @@ -1183,7 +1183,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -1210,7 +1210,7 @@ describe('IndexPattern Data Source', () => { columnOrder: [], columns: {}, incompleteColumns: { - col1: { operationType: 'avg' as const }, + col1: { operationType: 'average' as const }, col2: { operationType: 'sum' as const }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 210716e7494e0..e742b6ba62aff 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -735,7 +735,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'bytes', label: 'Avg of bytes', customLabel: true, - operationType: 'avg', + operationType: 'average', }, }, columnOrder: ['cola', 'colb'], @@ -770,7 +770,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'bytes', label: 'Avg of bytes', customLabel: true, - operationType: 'avg', + operationType: 'average', }, }, }, @@ -1060,7 +1060,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref: { @@ -1120,7 +1120,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref: { @@ -1468,7 +1468,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1537,7 +1537,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1601,7 +1601,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', - operationType: 'cardinality', + operationType: 'unique_count', }, colb: { label: 'My Op', @@ -1647,7 +1647,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', - operationType: 'cardinality', + operationType: 'unique_count', }, colb: { label: 'My Custom Range', @@ -1723,7 +1723,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1843,7 +1843,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field4', }, col5: { @@ -1951,7 +1951,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field1', }, }, @@ -2031,7 +2031,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field1', }, }, @@ -2091,7 +2091,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 381fa4ca27a49..a7c1074ed4eef 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -165,7 +165,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index f4fa8bd185b6d..a68f8ae310f3e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -145,7 +145,7 @@ const indexPattern2 = ({ agg: 'histogram', interval: 1000, }, - avg: { + average: { agg: 'avg', }, max: { @@ -569,7 +569,7 @@ describe('loader', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'myfield', }, }, @@ -582,7 +582,7 @@ describe('loader', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'myfield2', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 7052a69ee6fb7..ec7ef6a37a27a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -17,7 +17,7 @@ import { IndexPatternField, IndexPatternLayer, } from './types'; -import { updateLayerIndexPattern } from './operations'; +import { updateLayerIndexPattern, translateToOperationName } from './operations'; import { DateRange, ExistingFields } from '../../common/types'; import { BASE_API_URL } from '../../common'; import { @@ -109,7 +109,7 @@ export async function loadIndexPatterns({ const restriction = typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]; if (restriction) { - restrictionsObj[agg] = restriction; + restrictionsObj[translateToOperationName(agg)] = restriction; } }); if (Object.keys(restrictionsObj).length) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx similarity index 95% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 5fb0bc3a83528..c50e9270eaac1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -19,6 +19,8 @@ import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { OperationDefinition } from '..'; import { getFormatFromPreviousColumn } from '../helpers'; +const OPERATION_NAME = 'differences'; + const ofName = buildLabelFunction((name?: string) => { return i18n.translate('xpack.lens.indexPattern.derivativeOf', { defaultMessage: 'Differences of {name}', @@ -34,14 +36,14 @@ const ofName = buildLabelFunction((name?: string) => { export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn & ReferenceBasedIndexPatternColumn & { - operationType: 'derivative'; + operationType: typeof OPERATION_NAME; }; export const derivativeOperation: OperationDefinition< DerivativeIndexPatternColumn, 'fullReference' > = { - type: 'derivative', + type: OPERATION_NAME, priority: 1, displayName: i18n.translate('xpack.lens.indexPattern.derivative', { defaultMessage: 'Differences', @@ -78,7 +80,7 @@ export const derivativeOperation: OperationDefinition< previousColumn?.timeScale ), dataType: 'number', - operationType: 'derivative', + operationType: OPERATION_NAME, isBucketed: false, scale: 'ratio', references: referenceIds, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts index f261a0e1e2005..815acb8c4169f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts @@ -7,5 +7,5 @@ export { counterRateOperation, CounterRateIndexPatternColumn } from './counter_rate'; export { cumulativeSumOperation, CumulativeSumIndexPatternColumn } from './cumulative_sum'; -export { derivativeOperation, DerivativeIndexPatternColumn } from './derivative'; +export { derivativeOperation, DerivativeIndexPatternColumn } from './differences'; export { movingAverageOperation, MovingAverageIndexPatternColumn } from './moving_average'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index 513bac14af7a3..fa1691ba9a78e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -25,7 +25,7 @@ const supportedTypes = new Set([ ]); const SCALE = 'ratio'; -const OPERATION_TYPE = 'cardinality'; +const OPERATION_TYPE = 'unique_count'; const IS_BUCKETED = false; function ofName(name: string) { @@ -40,7 +40,7 @@ function ofName(name: string) { export interface CardinalityIndexPatternColumn extends FormattedIndexPatternColumn, FieldBasedIndexPatternColumn { - operationType: 'cardinality'; + operationType: typeof OPERATION_TYPE; } export const cardinalityOperation: OperationDefinition = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 03f8375409246..bf563b877ef5e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -26,6 +26,7 @@ import { buildExpressionFunction } from '../../../../../../../../src/plugins/exp import { NewBucketButton, DragDropBuckets, DraggableBucketContainer } from '../shared_components'; const generateId = htmlIdGenerator(); +const OPERATION_NAME = 'filters'; // references types from src/plugins/data/common/search/aggs/buckets/filters.ts export interface Filter { @@ -70,14 +71,14 @@ export const isQueryValid = (input: Query, indexPattern: IndexPattern) => { }; export interface FiltersIndexPatternColumn extends BaseIndexPatternColumn { - operationType: 'filters'; + operationType: typeof OPERATION_NAME; params: { filters: Filter[]; }; } export const filtersOperation: OperationDefinition = { - type: 'filters', + type: OPERATION_NAME, displayName: filtersLabel, priority: 3, // Higher than any metric input: 'none', @@ -86,7 +87,7 @@ export const filtersOperation: OperationDefinition filtersLabel, buildColumn({ previousColumn }) { let params = { filters: [defaultFilter] }; - if (previousColumn?.operationType === 'terms') { + if (previousColumn?.operationType === 'terms' && 'sourceField' in previousColumn) { params = { filters: [ { @@ -103,7 +104,7 @@ export const filtersOperation: OperationDefinition { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', // <= invalid + operationType: 'average', // <= invalid sourceField: 'timestamp', }, createMockedIndexPattern() @@ -46,7 +46,7 @@ describe('helpers', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, createMockedIndexPattern() diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index cdb93048c9a58..b3aa93b062eb1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -437,3 +437,11 @@ export const operationDefinitionMap: Record< (definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }), {} ); + +/** + * Cannot map the prev names, but can guarantee the new names are matching up using the type system + */ +export const renameOperationsMapping: Record = { + avg: 'average', + cardinality: 'unique_count', +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 1767b76e88202..20580634d12e6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -27,7 +27,7 @@ type MetricColumn = FormattedIndexPatternColumn & const typeToFn: Record = { min: 'aggMin', max: 'aggMax', - avg: 'aggAvg', + average: 'aggAvg', sum: 'aggSum', median: 'aggMedian', }; @@ -76,7 +76,6 @@ function buildMetricOperation>({ }, isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.getFieldByName(column.sourceField); - return Boolean( newField && supportedTypes.includes(newField.type) && @@ -124,7 +123,7 @@ function buildMetricOperation>({ } export type SumIndexPatternColumn = MetricColumn<'sum'>; -export type AvgIndexPatternColumn = MetricColumn<'avg'>; +export type AvgIndexPatternColumn = MetricColumn<'average'>; export type MinIndexPatternColumn = MetricColumn<'min'>; export type MaxIndexPatternColumn = MetricColumn<'max'>; export type MedianIndexPatternColumn = MetricColumn<'median'>; @@ -154,7 +153,7 @@ export const maxOperation = buildMetricOperation({ }); export const averageOperation = buildMetricOperation({ - type: 'avg', + type: 'average', priority: 2, displayName: i18n.translate('xpack.lens.indexPattern.avg', { defaultMessage: 'Average', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index a31cf9f019480..639b9e3a95c47 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -74,7 +74,10 @@ export const percentileOperation: OperationDefinition { const existingPercentileParam = - previousColumn?.operationType === 'percentile' && previousColumn?.params.percentile; + previousColumn?.operationType === 'percentile' && + previousColumn.params && + 'percentile' in previousColumn.params && + previousColumn.params.percentile; const newPercentileParam = existingPercentileParam || DEFAULT_PERCENTILE_VALUE; return { label: ofName(getSafeName(field.name, indexPattern), newPercentileParam), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 3b0cb67cbce41..a4a061db04797 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -13,9 +13,7 @@ import { EuiSwitch, EuiSwitchEvent, EuiSpacer, - EuiPopover, - EuiButtonEmpty, - EuiText, + EuiAccordion, EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; @@ -24,7 +22,7 @@ import { updateColumnParam, isReferenced } from '../../layer_helpers'; import { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import { getEsAggsSuffix, getInvalidFieldMessage } from '../helpers'; import type { IndexPatternLayer } from '../../../types'; @@ -193,8 +191,6 @@ export const termsOperation: OperationDefinition - { updateLayer( @@ -251,71 +247,6 @@ export const termsOperation: OperationDefinition - {!hasRestrictions && ( - - { - setPopoverOpen(!popoverOpen); - }} - > - {i18n.translate('xpack.lens.indexPattern.terms.advancedSettings', { - defaultMessage: 'Advanced', - })} - - } - isOpen={popoverOpen} - closePopover={() => { - setPopoverOpen(false); - }} - > - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'otherBucket', - value: e.target.checked, - }) - ) - } - /> - - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'missingBucket', - value: e.target.checked, - }) - ) - } - /> - - - - )} @@ -415,6 +346,57 @@ export const termsOperation: OperationDefinition + {!hasRestrictions && ( + <> + + + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'otherBucket', + value: e.target.checked, + }) + ) + } + /> + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'missingBucket', + value: e.target.checked, + }) + ) + } + /> + + + )} ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 0ed611e9726ef..97b57dee2fde7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; -import { EuiRange, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { EuiFieldNumber, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../../mocks'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; @@ -888,7 +888,7 @@ describe('terms', () => { /> ); - expect(instance.find(EuiRange).prop('value')).toEqual('3'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('3'); }); it('should update state with the size value', () => { @@ -904,7 +904,7 @@ describe('terms', () => { ); act(() => { - instance.find(ValuesRangeInput).prop('onChange')!(7); + instance.find(ValuesInput).prop('onChange')!(7); }); expect(updateLayerSpy).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx similarity index 50% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx index 3603188ba30e5..4303695d6e293 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx @@ -8,52 +8,50 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; -import { EuiRange } from '@elastic/eui'; -import { ValuesRangeInput } from './values_range_input'; +import { EuiFieldNumber } from '@elastic/eui'; +import { ValuesInput } from './values_input'; jest.mock('react-use/lib/useDebounce', () => (fn: () => void) => fn()); -describe('ValuesRangeInput', () => { - it('should render EuiRange correctly', () => { +describe('Values', () => { + it('should render EuiFieldNumber correctly', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); - expect(instance.find(EuiRange).prop('value')).toEqual('5'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('5'); }); it('should not run onChange function on mount', () => { const onChangeSpy = jest.fn(); - shallow(); + shallow(); expect(onChangeSpy.mock.calls.length).toBe(0); }); it('should run onChange function on update', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '7' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '7' }, + } as React.ChangeEvent); }); - expect(instance.find(EuiRange).prop('value')).toEqual('7'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('7'); expect(onChangeSpy.mock.calls.length).toBe(1); expect(onChangeSpy.mock.calls[0][0]).toBe(7); }); it('should not run onChange function on update when value is out of 1-100 range', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '107' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '1007' }, + } as React.ChangeEvent); }); instance.update(); - expect(instance.find(EuiRange).prop('value')).toEqual('107'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('1007'); expect(onChangeSpy.mock.calls.length).toBe(1); - expect(onChangeSpy.mock.calls[0][0]).toBe(100); + expect(onChangeSpy.mock.calls[0][0]).toBe(1000); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx similarity index 88% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 068e13429527f..915e67c4eba0b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -7,10 +7,10 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiRange } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; import { useDebounceWithOptions } from '../helpers'; -export const ValuesRangeInput = ({ +export const ValuesInput = ({ value, onChange, }: { @@ -18,7 +18,7 @@ export const ValuesRangeInput = ({ onChange: (value: number) => void; }) => { const MIN_NUMBER_OF_VALUES = 1; - const MAX_NUMBER_OF_VALUES = 100; + const MAX_NUMBER_OF_VALUES = 1000; const [inputValue, setInputValue] = useState(String(value)); @@ -36,13 +36,11 @@ export const ValuesRangeInput = ({ ); return ( - setInputValue(currentTarget.value)} aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 4a05e6f372d30..62cce21ead636 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -120,7 +120,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -147,7 +147,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -365,7 +365,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col2: { @@ -533,7 +533,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col2: { @@ -840,7 +840,7 @@ describe('state_helpers', () => { }, indexPattern, columnId: 'col2', - op: 'avg', + op: 'average', field: indexPattern.fields[2], // bytes field visualizationGroups: [], }); @@ -856,7 +856,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', }), }, incompleteColumns: {}, @@ -1027,7 +1027,7 @@ describe('state_helpers', () => { dataType: 'number' as const, isBucketed: false, sourceField: 'bytes', - operationType: 'avg' as const, + operationType: 'average' as const, }, }, }; @@ -1056,7 +1056,7 @@ describe('state_helpers', () => { { input: ['field'], validateMetadata: () => true, - specificOperations: ['cardinality', 'sum', 'avg'], // this order is ignored + specificOperations: ['unique_count', 'sum', 'average'], // this order is ignored }, ]; const layer: IndexPatternLayer = { @@ -1083,7 +1083,7 @@ describe('state_helpers', () => { expect(result.columnOrder).toEqual(['id1', 'col1']); expect(result.columns).toEqual({ id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'average', }), col1: expect.objectContaining({ operationType: 'testReference', @@ -1097,7 +1097,7 @@ describe('state_helpers', () => { { input: ['field'], validateMetadata: () => true, - specificOperations: ['cardinality'], + specificOperations: ['unique_count'], }, ]; const layer: IndexPatternLayer = { @@ -1122,7 +1122,7 @@ describe('state_helpers', () => { }); expect(result.incompleteColumns).toEqual({ - id1: { operationType: 'cardinality' }, + id1: { operationType: 'unique_count' }, }); expect(result.columns).toEqual({ col1: expect.objectContaining({ @@ -1347,7 +1347,7 @@ describe('state_helpers', () => { columns: { id1: expect.objectContaining({ sourceField: 'timestamp', - operationType: 'cardinality', + operationType: 'unique_count', }), output: expect.objectContaining({ references: ['id1'] }), }, @@ -1463,7 +1463,7 @@ describe('state_helpers', () => { dataType: 'number' as const, isBucketed: false, sourceField: 'bytes', - operationType: 'avg' as const, + operationType: 'average' as const, }; const layer: IndexPatternLayer = { @@ -1475,7 +1475,7 @@ describe('state_helpers', () => { label: 'Reference', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['metric'], }, }, @@ -1829,7 +1829,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }; @@ -1915,7 +1915,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col3: { @@ -1963,7 +1963,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref2: { @@ -2006,7 +2006,7 @@ describe('state_helpers', () => { searchable: true, type: 'number', aggregationRestrictions: { - avg: { + average: { agg: 'avg', }, }, @@ -2076,7 +2076,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'xxx', }, }, @@ -2107,7 +2107,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'fieldB', }, }, @@ -2170,7 +2170,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'fieldD', }, }, @@ -2220,14 +2220,14 @@ describe('state_helpers', () => { describe('getErrorMessages', () => { it('should collect errors from metric-type operation definitions', () => { const mock = jest.fn().mockReturnValue(['error 1']); - operationDefinitionMap.avg.getErrorMessage = mock; + operationDefinitionMap.average.getErrorMessage = mock; const errors = getErrorMessages( { indexPatternId: '1', columnOrder: [], columns: { // @ts-expect-error invalid column - col1: { operationType: 'avg' }, + col1: { operationType: 'average' }, }, }, indexPattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 8c5dee8bbb28f..4c54b777b66f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -56,7 +56,7 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(['terms', 'cardinality', 'last_value']); + ).toEqual(['terms', 'unique_count', 'last_value']); }); it('should return only bucketed operations on strings when passed proper filterOperations function', () => { @@ -87,11 +87,11 @@ describe('getOperationTypesForField', () => { 'range', 'terms', 'median', - 'avg', + 'average', 'sum', 'min', 'max', - 'cardinality', + 'unique_count', 'percentile', 'last_value', ]); @@ -109,7 +109,16 @@ describe('getOperationTypesForField', () => { }, (op) => !op.isBucketed ) - ).toEqual(['median', 'avg', 'sum', 'min', 'max', 'cardinality', 'percentile', 'last_value']); + ).toEqual([ + 'median', + 'average', + 'sum', + 'min', + 'max', + 'unique_count', + 'percentile', + 'last_value', + ]); }); it('should return operations on dates', () => { @@ -286,7 +295,7 @@ describe('getOperationTypesForField', () => { }, Object { "field": "bytes", - "operationType": "avg", + "operationType": "average", "type": "field", }, Object { @@ -303,7 +312,7 @@ describe('getOperationTypesForField', () => { "type": "fullReference", }, Object { - "operationType": "derivative", + "operationType": "differences", "type": "fullReference", }, Object { @@ -322,17 +331,17 @@ describe('getOperationTypesForField', () => { }, Object { "field": "timestamp", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { "field": "bytes", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { "field": "source", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index 140ebc813f6c1..a45650f9323f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -12,12 +12,19 @@ import { operationDefinitions, GenericOperationDefinition, OperationType, + renameOperationsMapping, } from './definitions'; import { IndexPattern, IndexPatternField } from '../types'; import { documentField } from '../document_field'; export { operationDefinitionMap } from './definitions'; - +/** + * Map aggregation names from Elasticsearch to Lens names. + * Used when loading indexpatterns to map metadata (i.e. restrictions) + */ +export function translateToOperationName(agg: string): OperationType { + return agg in renameOperationsMapping ? renameOperationsMapping[agg] : (agg as OperationType); +} /** * Returns all available operation types as a list at runtime. * This will be an array of each member of the union type `OperationType` diff --git a/x-pack/plugins/lens/public/mocks.ts b/x-pack/plugins/lens/public/mocks.ts index 10d3be1d1b57d..fd1e38db242a8 100644 --- a/x-pack/plugins/lens/public/mocks.ts +++ b/x-pack/plugins/lens/public/mocks.ts @@ -14,6 +14,7 @@ const createStartContract = (): Start => { EmbeddableComponent: jest.fn(() => null), canUseEditor: jest.fn(() => true), navigateToPrefilledEditor: jest.fn(), + getXyVisTypes: jest.fn(), }; return startContract; }; diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx index 34619ae59ae5f..8796f619277ff 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { render } from 'react-dom'; import { NativeRenderer } from './native_renderer'; import { act } from 'react-dom/test-utils'; @@ -151,4 +151,102 @@ describe('native_renderer', () => { const containerElement: Element = mountpoint.firstElementChild!; expect(containerElement.nodeName).toBe('SPAN'); }); + + it('should properly unmount a react element that is mounted inside the renderer', () => { + let isUnmounted = false; + + function TestComponent() { + useEffect(() => { + return () => { + isUnmounted = true; + }; + }, []); + return <>Hello; + } + + renderAndTriggerHooks( + { + // This render function mimics the most common usage inside Lens + render(, element); + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(isUnmounted).toBe(true); + }); + + it('should call the unmount function provided for non-react elements', () => { + const unmountCallback = jest.fn(); + + renderAndTriggerHooks( + { + return unmountCallback; + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(unmountCallback).toHaveBeenCalled(); + }); + + it('should handle when the mount function is asynchronous without a cleanup fn', () => { + let isUnmounted = false; + + function TestComponent() { + useEffect(() => { + return () => { + isUnmounted = true; + }; + }, []); + return <>Hello; + } + + renderAndTriggerHooks( + { + render(, element); + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(isUnmounted).toBe(true); + }); + + it('should handle when the mount function is asynchronous with a cleanup fn', async () => { + const unmountCallback = jest.fn(); + + renderAndTriggerHooks( + { + return unmountCallback; + }} + nativeProps={{}} + />, + mountpoint + ); + + // Schedule a promise cycle to update the DOM + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(unmountCallback).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx index 68563e01d7f3f..f0659a130b293 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -5,10 +5,16 @@ * 2.0. */ -import React, { HTMLAttributes } from 'react'; +import React, { HTMLAttributes, useEffect, useRef } from 'react'; +import { unmountComponentAtNode } from 'react-dom'; + +type CleanupCallback = (el: Element) => void; export interface NativeRendererProps extends HTMLAttributes { - render: (domElement: Element, props: T) => void; + render: ( + domElement: Element, + props: T + ) => Promise | CleanupCallback | void; nativeProps: T; tag?: string; } @@ -19,11 +25,42 @@ export interface NativeRendererProps extends HTMLAttributes { * By default the mountpoint element will be a div, this can be changed with the * `tag` prop. * + * If the rendered component tree was using React, we need to clean it up manually, + * otherwise the unmount event never happens. A future addition is for non-React components + * to get cleaned up, which could be added in the future. + * * @param props */ export function NativeRenderer({ render, nativeProps, tag, ...rest }: NativeRendererProps) { + const elementRef = useRef(); + const cleanupRef = useRef<((cleanupElement: Element) => void) | void>(); + useEffect(() => { + return () => { + if (elementRef.current) { + if (cleanupRef.current && typeof cleanupRef.current === 'function') { + cleanupRef.current(elementRef.current); + } + unmountComponentAtNode(elementRef.current); + } + }; + }, []); return React.createElement(tag || 'div', { ...rest, - ref: (el) => el && render(el, nativeProps), + ref: (el) => { + if (el) { + elementRef.current = el; + // Handles the editor frame renderer, which is async + const result = render(el, nativeProps); + if (result instanceof Promise) { + result.then((cleanup) => { + if (typeof cleanup === 'function') { + cleanupRef.current = cleanup; + } + }); + } else if (typeof result === 'function') { + cleanupRef.current = result; + } + } + }, }); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index fc7e4464624f4..aed4db2e88e21 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -42,7 +42,7 @@ import { VISUALIZE_FIELD_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; -import { EditorFrameStart } from './types'; +import type { EditorFrameStart, VisualizationType } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; import { getSearchProvider } from './search_provider'; @@ -101,6 +101,11 @@ export interface LensPublicStart { * Method which returns true if the user has permission to use Lens as defined by application capabilities. */ canUseEditor: () => boolean; + + /** + * Method which returns xy VisualizationTypes array keeping this async as to not impact page load bundle + */ + getXyVisTypes: () => Promise; } export class LensPlugin { @@ -257,6 +262,10 @@ export class LensPlugin { canUseEditor: () => { return Boolean(core.application.capabilities.visualize?.show); }, + getXyVisTypes: async () => { + const { visualizationTypes } = await import('./xy_visualization/types'); + return visualizationTypes; + }, }; } diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index c0788e6f67dfe..18c73a01cf784 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -15,6 +15,7 @@ const typeToIconMap: { [type: string]: string | IconType } = { labels: 'visText', values: 'number', list: 'list', + visualOptions: 'brush', }; export interface ToolbarPopoverProps { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 6c88eb20826bb..3d34d22c5048a 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -17,7 +17,7 @@ import { Datatable, SerializedFieldFormat, } from '../../../../src/plugins/expressions/public'; -import { DragContextState, DragDropIdentifier } from './drag_drop'; +import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import { Document } from './persistence'; import { DateRange } from '../common'; import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; @@ -64,7 +64,7 @@ export interface EditorFrameProps { showNoDataPopover: () => void; } export interface EditorFrameInstance { - mount: (element: Element, props: EditorFrameProps) => void; + mount: (element: Element, props: EditorFrameProps) => Promise; unmount: () => void; } @@ -142,11 +142,16 @@ export type DropType = | 'field_add' | 'field_replace' | 'reorder' - | 'duplicate_in_group' | 'move_compatible' | 'replace_compatible' | 'move_incompatible' - | 'replace_incompatible'; + | 'replace_incompatible' + | 'replace_duplicate_compatible' + | 'duplicate_compatible' + | 'swap_compatible' + | 'replace_duplicate_incompatible' + | 'duplicate_incompatible' + | 'swap_incompatible'; export interface DatasourceSuggestion { state: T; @@ -185,16 +190,28 @@ export interface Datasource { getLayers: (state: T) => string[]; removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; - renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; - renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps) => void; - renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; - renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; + renderDataPanel: ( + domElement: Element, + props: DatasourceDataPanelProps + ) => ((cleanupElement: Element) => void) | void; + renderDimensionTrigger: ( + domElement: Element, + props: DatasourceDimensionTriggerProps + ) => ((cleanupElement: Element) => void) | void; + renderDimensionEditor: ( + domElement: Element, + props: DatasourceDimensionEditorProps + ) => ((cleanupElement: Element) => void) | void; + renderLayerPanel: ( + domElement: Element, + props: DatasourceLayerPanelProps + ) => ((cleanupElement: Element) => void) | void; getDropProps: ( props: DatasourceDimensionDropProps & { groupId: string; dragging: DragContextState['dragging']; } - ) => { dropType: DropType; nextLabel?: string } | undefined; + ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; updateStateOnCloseDimension?: (props: { layerId: string; @@ -295,10 +312,11 @@ export interface DatasourceLayerPanelProps { activeData?: Record; } -export interface DraggedOperation { +export interface DraggedOperation extends DraggingIdentifier { layerId: string; groupId: string; columnId: string; + filterOperations: (operation: OperationMetadata) => boolean; } export function isDraggedOperation( @@ -585,12 +603,18 @@ export interface Visualization { * Popover contents that open when the user clicks the contextMenuIcon. This can be used * for extra configurability, such as for styling the legend or axis */ - renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps) => void; + renderLayerContextMenu?: ( + domElement: Element, + props: VisualizationLayerWidgetProps + ) => ((cleanupElement: Element) => void) | void; /** * Toolbar rendered above the visualization. This is meant to be used to provide chart-level * settings for the visualization. */ - renderToolbar?: (domElement: Element, props: VisualizationToolbarProps) => void; + renderToolbar?: ( + domElement: Element, + props: VisualizationToolbarProps + ) => ((cleanupElement: Element) => void) | void; /** * Visualizations can provide a custom icon which will open a layer-specific popover * If no icon is provided, gear icon is default @@ -620,7 +644,7 @@ export interface Visualization { renderDimensionEditor?: ( domElement: Element, props: VisualizationDimensionEditorProps - ) => void; + ) => ((cleanupElement: Element) => void) | void; /** * The frame will call this function on all visualizations at different times. The diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 982f513ae1019..1130bd7a95d88 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -27,6 +27,9 @@ Object { "type": "expression", }, ], + "curveType": Array [ + "LINEAR", + ], "description": Array [ "", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 0bf5c139e2403..5615a9ac34898 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -24,6 +24,7 @@ import { HorizontalAlignment, ElementClickListener, BrushEndListener, + CurveType, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -179,6 +180,13 @@ export const xyChart: ExpressionFunctionDefinition< help: 'Layers of visual series', multi: true, }, + curveType: { + types: ['string'], + options: ['LINEAR', 'CURVE_MONOTONE_X'], + help: i18n.translate('xpack.lens.xyChart.curveType.help', { + defaultMessage: 'Define how curve type is rendered for a line chart', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -773,10 +781,17 @@ export function XYChart({ const index = `${layerIndex}-${accessorIndex}`; + const curveType = args.curveType ? CurveType[args.curveType] : undefined; + switch (seriesType) { case 'line': return ( - + ); case 'bar': case 'bar_stacked': @@ -804,11 +819,17 @@ export function XYChart({ key={index} {...seriesProps} fit={isPercentage ? 'zero' : getFitOptions(fittingFunction)} + curve={curveType} /> ); case 'area': return ( - + ); default: return assertNever(seriesType); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 331e27a8efdb0..6a1882edde949 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -148,6 +148,7 @@ export const buildExpression = ( }, ], fittingFunction: [state.fittingFunction || 'None'], + curveType: [state.curveType || 'LINEAR'], axisTitlesVisibilitySettings: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 126be41e7b129..6f1a01acd6e76 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -413,8 +413,11 @@ export interface XYArgs { }; tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; + curveType?: XYCurveType; } +export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; + // Persisted parts of the state export interface XYState { preferredSeriesType: SeriesType; @@ -428,6 +431,7 @@ export interface XYState { axisTitlesVisibilitySettings?: AxesSettingsConfig; tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; + curveType?: XYCurveType; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx new file mode 100644 index 0000000000000..c37a36a42fa47 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 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 as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; +import { EuiSwitch } from '@elastic/eui'; +import { LineCurveOption } from './line_curve_option'; + +describe('Line curve option', () => { + it('should show currently selected line curve option', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(true); + }); + + it('should show currently curving disabled', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(false); + }); + + it('should show curving option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(true); + }); + + it('should hide curve option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx new file mode 100644 index 0000000000000..ea0a1553ba5e5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { XYCurveType } from '../types'; + +export interface LineCurveOptionProps { + /** + * Currently selected value + */ + value?: XYCurveType; + /** + * Callback on display option change + */ + onChange: (id: XYCurveType) => void; + isCurveTypeEnabled?: boolean; +} + +export const LineCurveOption: React.FC = ({ + onChange, + value, + isCurveTypeEnabled = true, +}) => { + return isCurveTypeEnabled ? ( + <> + + { + if (e.target.checked) { + onChange('CURVE_MONOTONE_X'); + } else { + onChange('LINEAR'); + } + }} + data-test-subj="lnsCurveStyleToggle" + /> + + + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx new file mode 100644 index 0000000000000..851b14839d7f7 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 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 as shallow, mountWithIntl as mount } from '@kbn/test/jest'; +import { EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Missing values option', () => { + it('should show currently selected fitting function', () => { + const component = shallow( + + ); + + expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); + }); + + it('should show currently selected value labels display setting', () => { + const component = mount( + + ); + + expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); + }); + + it('should show display field when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); + }); + + it('should hide in display value label option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); + }); + + it('should show the fitting option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(true); + }); + + it('should hide the fitting option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx new file mode 100644 index 0000000000000..fb6ecec4d2801 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 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 { i18n } from '@kbn/i18n'; +import { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { FittingFunction, fittingFunctionDefinitions } from '../fitting_functions'; +import { ValueLabelConfig } from '../types'; + +export interface MissingValuesOptionProps { + valueLabels?: ValueLabelConfig; + fittingFunction?: FittingFunction; + onValueLabelChange: (newMode: ValueLabelConfig) => void; + onFittingFnChange: (newMode: FittingFunction) => void; + isValueLabelsEnabled?: boolean; + isFittingEnabled?: boolean; +} + +const valueLabelsOptions: Array<{ + id: string; + value: 'hide' | 'inside' | 'outside'; + label: string; + 'data-test-subj': string; +}> = [ + { + id: `value_labels_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lnsXY_valueLabels_hide', + }, + { + id: `value_labels_inside`, + value: 'inside', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { + defaultMessage: 'Show', + }), + 'data-test-subj': 'lnsXY_valueLabels_inside', + }, +]; + +export const MissingValuesOptions: React.FC = ({ + onValueLabelChange, + onFittingFnChange, + valueLabels, + fittingFunction, + isValueLabelsEnabled = true, + isFittingEnabled = true, +}) => { + const valueLabelsVisibilityMode = valueLabels || 'hide'; + + return ( + <> + {isValueLabelsEnabled && ( + + {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { + defaultMessage: 'Labels', + })} + + } + > + value === valueLabelsVisibilityMode)!.id + } + onChange={(modeId) => { + const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; + onValueLabelChange(newMode); + }} + /> + + )} + {isFittingEnabled && ( + + {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { + defaultMessage: 'Missing values', + })}{' '} + + + } + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={fittingFunction || 'None'} + onChange={(value) => onFittingFnChange(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ )} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx new file mode 100644 index 0000000000000..e7ec395312bff --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallowWithIntl as shallow } from '@kbn/test/jest'; +import { Position } from '@elastic/charts'; +import { FramePublicAPI } from '../../types'; +import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { State } from '../types'; +import { VisualOptionsPopover } from './visual_options_popover'; +import { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Visual options popover', () => { + let frame: FramePublicAPI; + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + layers: [ + { + seriesType: 'bar', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + } + + beforeEach(() => { + frame = createMockFramePublicAPI(); + frame.datasourceLayers = { + first: createMockDatasource('test').publicAPIMock, + }; + }); + it('should disable the visual options for stacked bar charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should disable the values and fitting for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should not disable the visual options for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(false); + }); + + it('should disabled the popover if there is histogram series', () => { + // make it detect an histogram series + frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ + isBucketed: true, + scale: 'interval', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should hide the fitting option for bar series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should show the popover and display field enabled for bar and horizontal_bar series', () => { + const state = testState(); + + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); + + it('should hide in the popover the display option for area and line series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + }); + + it('should keep the display option for bar series with multiple layers', () => { + frame.datasourceLayers = { + ...frame.datasourceLayers, + second: createMockDatasource('test').publicAPIMock, + }; + + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx new file mode 100644 index 0000000000000..fcdef86cc5d0e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -0,0 +1,116 @@ +/* + * Copyright 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 { i18n } from '@kbn/i18n'; +import { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; +import { LineCurveOption } from './line_curve_option'; +import { XYState } from '../types'; +import { hasHistogramSeries } from '../state_helpers'; +import { ValidLayer } from '../types'; +import { TooltipWrapper } from '../tooltip_wrapper'; +import { FramePublicAPI } from '../../types'; + +function getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, +}: { + isAreaPercentage: boolean; + isHistogramSeries: boolean; +}): string { + if (isHistogramSeries) { + return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on histograms.', + }); + } + if (isAreaPercentage) { + return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on percentage area charts.', + }); + } + return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', + }); +} + +export interface VisualOptionsPopoverProps { + state: XYState; + setState: (newState: XYState) => void; + datasourceLayers: FramePublicAPI['datasourceLayers']; +} + +export const VisualOptionsPopover: React.FC = ({ + state, + setState, + datasourceLayers, +}) => { + const isAreaPercentage = state?.layers.some( + ({ seriesType }) => seriesType === 'area_percentage_stacked' + ); + + const hasNonBarSeries = state?.layers.some(({ seriesType }) => + ['area_stacked', 'area', 'line'].includes(seriesType) + ); + + const hasBarNotStacked = state?.layers.some(({ seriesType }) => + ['bar', 'bar_horizontal'].includes(seriesType) + ); + + const isHistogramSeries = Boolean( + hasHistogramSeries(state?.layers as ValidLayer[], datasourceLayers) + ); + + const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; + const isFittingEnabled = hasNonBarSeries; + const isCurveTypeEnabled = hasNonBarSeries || isAreaPercentage; + + const valueLabelsDisabledReason = getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, + }); + + const isDisabled = !isValueLabelsEnabled && !isFittingEnabled && !isCurveTypeEnabled; + + return ( + + + { + setState({ + ...state, + curveType: id, + }); + }} + /> + + { + setState({ ...state, valueLabels: newMode }); + }} + onFittingFnChange={(newVal) => { + setState({ ...state, fittingFunction: newVal }); + }} + /> + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 40ac4958aefb9..f965140a48ca0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; -import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; import { State } from './types'; @@ -101,179 +100,6 @@ describe('XY Config panels', () => { }); describe('XyToolbar', () => { - it('should show currently selected fitting function', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); - }); - - it('should show currently selected value labels display setting', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); - }); - - it('should disable the popover for stacked bar charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disable the popover for percentage area charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disabled the popover if there is histogram series', () => { - // make it detect an histogram series - frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ - isBucketed: true, - scale: 'interval', - }); - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should show the popover and display field enabled for bar and horizontal_bar series', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - - it('should hide the fitting option for bar series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); - }); - - it('should hide in the popover the display option for area and line series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); - }); - - it('should keep the display option for bar series with multiple layers', () => { - frame.datasourceLayers = { - ...frame.datasourceLayers, - second: createMockDatasource('test').publicAPIMock, - }; - - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - it('should disable the popover if there is no right axis', () => { const state = testState(); const component = shallow(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index ac08c55eeadbf..d7868a17bf9db 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -14,15 +14,12 @@ import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, - EuiSuperSelect, EuiFormRow, - EuiText, htmlIdGenerator, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon, - EuiIconTip, } from '@elastic/eui'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { @@ -31,29 +28,17 @@ import { VisualizationDimensionEditorProps, FormatFactory, } from '../types'; -import { - State, - SeriesType, - visualizationTypes, - YAxisMode, - AxesSettingsConfig, - ValidLayer, -} from './types'; -import { - isHorizontalChart, - isHorizontalSeries, - getSeriesColor, - hasHistogramSeries, -} from './state_helpers'; +import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types'; +import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { fittingFunctionDefinitions } from './fitting_functions'; -import { ToolbarPopover, LegendSettingsPopover } from '../shared_components'; +import { LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getSortedAccessors } from './to_expression'; +import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -92,30 +77,6 @@ const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: }, ]; -const valueLabelsOptions: Array<{ - id: string; - value: 'hide' | 'inside' | 'outside'; - label: string; - 'data-test-subj': string; -}> = [ - { - id: `value_labels_hide`, - value: 'hide', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { - defaultMessage: 'Hide', - }), - 'data-test-subj': 'lnsXY_valueLabels_hide', - }, - { - id: `value_labels_inside`, - value: 'inside', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { - defaultMessage: 'Show', - }), - 'data-test-subj': 'lnsXY_valueLabels_inside', - }, -]; - export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -159,46 +120,9 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } -function getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, -}: { - isAreaPercentage: boolean; - isHistogramSeries: boolean; -}): string { - if (isHistogramSeries) { - return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on histograms.', - }); - } - if (isAreaPercentage) { - return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on percentage area charts.', - }); - } - return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', - }); -} export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; - const hasNonBarSeries = state?.layers.some(({ seriesType }) => - ['area_stacked', 'area', 'line'].includes(seriesType) - ); - - const hasBarNotStacked = state?.layers.some(({ seriesType }) => - ['bar', 'bar_horizontal'].includes(seriesType) - ); - - const isAreaPercentage = state?.layers.some( - ({ seriesType }) => seriesType === 'area_percentage_stacked' - ); - - const isHistogramSeries = Boolean( - hasHistogramSeries(state?.layers as ValidLayer[], frame.datasourceLayers) - ); - const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false; const axisGroups = getAxesConfiguration(state?.layers, shouldRotate); @@ -267,113 +191,15 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp ? 'hide' : 'show'; - const valueLabelsVisibilityMode = state?.valueLabels || 'hide'; - - const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; - const isFittingEnabled = hasNonBarSeries; - - const valueLabelsDisabledReason = getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, - }); - return ( - - - {isValueLabelsEnabled ? ( - - {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { - defaultMessage: 'Labels', - })} - - } - > - value === valueLabelsVisibilityMode)! - .id - } - onChange={(modeId) => { - const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; - setState({ ...state, valueLabels: newMode }); - }} - /> - - ) : null} - {isFittingEnabled ? ( - - {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { - defaultMessage: 'Missing values', - })}{' '} - - - } - > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; - })} - valueOfSelected={state?.fittingFunction || 'None'} - onChange={(value) => setState({ ...state, fittingFunction: value })} - itemLayoutAlign="top" - hasDividers - /> -
- ) : null} -
-
+ { expect(result.attributes.title).toEqual(example.attributes.title); }); }); + + describe('7.13.0 rename operations for Formula', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '21c145c0-8667-11eb-b6a9-a5bf52bdf519', + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '5ab74ddc-93ca-44e2-9857-ecf85c86b53e': { + columns: { + '2e57a41e-5a52-42d3-877f-bd211d903ef8': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0': { + label: 'Unique count of agent.keyword', + dataType: 'number', + operationType: 'cardinality', + scale: 'ratio', + sourceField: 'agent.keyword', + isBucketed: false, + }, + 'e5efca70-edb5-4d6d-a30a-79384066987e': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'avg', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f': { + label: 'Differences of bytes', + dataType: 'number', + operationType: 'derivative', + isBucketed: false, + scale: 'ratio', + references: ['9ca33a9b-f2e6-46ef-a5e1-14bfbe262605'], + }, + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'avg', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '2e57a41e-5a52-42d3-877f-bd211d903ef8', + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + }; + + it('should rename only specific operation types', () => { + const result = migrations['7.13.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + const layers = result.attributes.state.datasourceStates.indexpattern.layers; + expect(layers).toEqual({ + '5ab74ddc-93ca-44e2-9857-ecf85c86b53e': { + columns: { + '2e57a41e-5a52-42d3-877f-bd211d903ef8': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0': { + label: 'Unique count of agent.keyword', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'agent.keyword', + isBucketed: false, + }, + 'e5efca70-edb5-4d6d-a30a-79384066987e': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'average', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f': { + label: 'Differences of bytes', + dataType: 'number', + operationType: 'differences', + isBucketed: false, + scale: 'ratio', + references: ['9ca33a9b-f2e6-46ef-a5e1-14bfbe262605'], + }, + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'average', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '2e57a41e-5a52-42d3-877f-bd211d903ef8', + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + incompleteColumns: {}, + }, + }); + // should leave other parts alone + expect(result.attributes.state.visualization).toEqual(example.attributes.state.visualization); + expect(result.attributes.state.query).toEqual(example.attributes.state.query); + expect(result.attributes.state.filters).toEqual(example.attributes.state.filters); + expect(result.attributes.title).toEqual(example.attributes.title); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index 4c6dfcd7949be..430c1a6caa667 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -106,6 +106,86 @@ interface DatatableStatePost711 { }; } +type OperationTypePre712 = + | 'avg' + | 'cardinality' + | 'derivative' + | 'filters' + | 'terms' + | 'date_histogram' + | 'min' + | 'max' + | 'sum' + | 'median' + | 'percentile' + | 'last_value' + | 'count' + | 'range' + | 'cumulative_sum' + | 'counter_rate' + | 'moving_average'; +type OperationTypePost712 = Exclude< + OperationTypePre712 | 'average' | 'unique_count' | 'differences', + 'avg' | 'cardinality' | 'derivative' +>; +interface LensDocShapePre712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePre712; + } + >; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} + +interface LensDocShapePost712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + currentIndexPatternId: string; + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePost712; + } + >; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} + /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} @@ -387,6 +467,44 @@ const transformTableState: SavedObjectMigrationFn< return newDoc; }; +const renameOperationsForFormula: SavedObjectMigrationFn< + LensDocShapePre712, + LensDocShapePost712 +> = (doc) => { + const renameMapping = { + avg: 'average', + cardinality: 'unique_count', + derivative: 'differences', + } as const; + function shouldBeRenamed(op: OperationTypePre712): op is keyof typeof renameMapping { + return op in renameMapping; + } + const newDoc = cloneDeep(doc); + const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {}; + (newDoc.attributes as LensDocShapePost712).state.datasourceStates.indexpattern.layers = Object.fromEntries( + Object.entries(datasourceLayers).map(([layerId, layer]) => { + return [ + layerId, + { + ...layer, + columns: Object.fromEntries( + Object.entries(layer.columns).map(([columnId, column]) => { + const copy = { + ...column, + operationType: shouldBeRenamed(column.operationType) + ? renameMapping[column.operationType] + : column.operationType, + }; + return [columnId, copy]; + }) + ), + }, + ]; + }) + ); + return newDoc as SavedObjectUnsanitizedDoc; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -395,4 +513,5 @@ export const migrations: SavedObjectMigrationMap = { '7.10.0': extractReferences, '7.11.0': removeSuggestedPriority, '7.12.0': transformTableState, + '7.13.0': renameOperationsForFormula, }; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap index e668929358a6a..268ce4cbb5165 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; -exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap index 719fce35a2a68..b75dee3c78306 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap index a0f3948785a80..00737c708b824 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 52a2da596c10e..9f08c5f11c2a2 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index bc69ab5352a4f..c89d183282219 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -270,7 +270,7 @@ exports[`UploadLicense should display a modal when license requires acknowledgem paddingSize="l" >
diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts index 29f1c2555e030..c96b1e888ff9c 100644 --- a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts @@ -73,7 +73,7 @@ describe('SecurityCheckupService', () => { ?.getAttribute('href'); expect(docLink).toMatchInlineSnapshot( - `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/get-started-enable-security.html?blade=kibanasecuritymessage"` + `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/configuring-stack-security.html?blade=kibanasecuritymessage"` ); }); }); diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index 6c6782f800ca6..bcb8a6c975359 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 143384d160471..4c62179f9ed54 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { ENABLE_CASE_CONNECTOR } from '../../cases/common/constants'; + export const APP_ID = 'securitySolution'; export const SERVER_APP_ID = 'siem'; export const APP_NAME = 'Security'; @@ -171,7 +173,6 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; /* Rule notifications options */ -export const ENABLE_CASE_CONNECTOR = true; export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', '.slack', diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 8234c3a9a599d..70fe2b6187aa6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -22,7 +22,7 @@ import { Query, Language, Index, TimestampOverrideOrUndefined } from './schemas/ export const getQueryFilter = ( query: Query, language: Language, - filters: Array>, + filters: unknown, index: Index, lists: Array, excludeExceptions: boolean = true @@ -48,7 +48,7 @@ export const getQueryFilter = ( chunkSize: 1024, }); const initialQuery = { query, language }; - const allFilters = getAllFilters((filters as unknown) as Filter[], exceptionFilter); + const allFilters = getAllFilters(filters as Filter[], exceptionFilter); return buildEsQuery(indexPattern, initialQuery, allFilters, config); }; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 90e025de1dcc8..d9f67e31196ca 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,8 +15,10 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; +export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index cf2b234451f50..b35504fc88659 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -7,6 +7,7 @@ import { Client } from '@elastic/elasticsearch'; import seedrandom from 'seedrandom'; +// eslint-disable-next-line import/no-extraneous-dependencies import { KbnClient } from '@kbn/test'; import { AxiosResponse } from 'axios'; import { EndpointDocGenerator, TreeOptions, Event } from './generate_data'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index e9ae439d0ac8c..326795ae55662 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -5,8 +5,18 @@ * 2.0. */ -import { GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema } from './trusted_apps'; -import { ConditionEntryField, OperatingSystem } from '../types'; +import { + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, +} from './trusted_apps'; +import { + ConditionEntry, + ConditionEntryField, + NewTrustedApp, + OperatingSystem, + PutTrustedAppsRequestParams, +} from '../types'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -72,17 +82,18 @@ describe('When invoking Trusted Apps Schema', () => { }); describe('for POST Create', () => { - const createConditionEntry = (data?: T) => ({ + const createConditionEntry = (data?: T): ConditionEntry => ({ field: ConditionEntryField.PATH, type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', ...(data || {}), }); - const createNewTrustedApp = (data?: T) => ({ + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ name: 'Some Anti-Virus App', description: 'this one is ok', - os: 'windows', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, entries: [createConditionEntry()], ...(data || {}), }); @@ -329,4 +340,55 @@ describe('When invoking Trusted Apps Schema', () => { }); }); }); + + describe('for PUT Update', () => { + const createConditionEntry = (data?: T): ConditionEntry => ({ + field: ConditionEntryField.PATH, + type: 'match', + operator: 'included', + value: 'c:/programs files/Anti-Virus', + ...(data || {}), + }); + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ + name: 'Some Anti-Virus App', + description: 'this one is ok', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, + entries: [createConditionEntry()], + ...(data || {}), + }); + + const updateParams = (data?: T): PutTrustedAppsRequestParams => ({ + id: 'validId', + ...(data || {}), + }); + + const body = PutTrustedAppUpdateRequestSchema.body; + const params = PutTrustedAppUpdateRequestSchema.params; + + it('should not error on a valid message', () => { + const bodyMsg = createNewTrustedApp(); + const paramsMsg = updateParams(); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + expect(params.validate(paramsMsg)).toStrictEqual(paramsMsg); + }); + + it('should validate `id` params is required', () => { + expect(() => params.validate(updateParams({ id: undefined }))).toThrow(); + }); + + it('should validate `id` params to be string', () => { + expect(() => params.validate(updateParams({ id: 1 }))).toThrow(); + }); + + it('should validate `version`', () => { + const bodyMsg = createNewTrustedApp({ version: 'v1' }); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + }); + + it('should validate `version` must be string', () => { + const bodyMsg = createNewTrustedApp({ version: 1 }); + expect(() => body.validate(bodyMsg)).toThrow(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 6d40dc75fd1c1..e582744e1a141 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -6,8 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import { ConditionEntryField, OperatingSystem } from '../types'; -import { getDuplicateFields, isValidHash } from '../validation/trusted_apps'; +import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; +import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; export const DeleteTrustedAppsRequestSchema = { params: schema.object({ @@ -15,10 +15,17 @@ export const DeleteTrustedAppsRequestSchema = { }), }; +export const GetOneTrustedAppRequestSchema = { + params: schema.object({ + id: schema.string(), + }), +}; + export const GetTrustedAppsRequestSchema = { query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), + kuery: schema.maybe(schema.string()), }), }; @@ -40,18 +47,18 @@ const CommonEntrySchema = { schema.siblingRef('field'), ConditionEntryField.HASH, schema.string({ - validate: (hash) => + validate: (hash: string) => isValidHash(hash) ? undefined : `invalidField.${ConditionEntryField.HASH}`, }), schema.conditional( schema.siblingRef('field'), ConditionEntryField.PATH, schema.string({ - validate: (field) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.PATH}`, }), schema.string({ - validate: (field) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.SIGNER}`, }) ) @@ -99,7 +106,7 @@ const EntrySchemaDependingOnOS = schema.conditional( */ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { minSize: 1, - validate(entries) { + validate(entries: ConditionEntry[]) { return ( getDuplicateFields(entries) .map((field) => `duplicatedEntry.${field}`) @@ -108,8 +115,8 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }, }); -export const PostTrustedAppCreateRequestSchema = { - body: schema.object({ +const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => + schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), os: schema.oneOf([ @@ -117,6 +124,26 @@ export const PostTrustedAppCreateRequestSchema = { schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC), ]), + effectScope: schema.oneOf([ + schema.object({ + type: schema.literal('global'), + }), + schema.object({ + type: schema.literal('policy'), + policies: schema.arrayOf(schema.string({ minLength: 1 })), + }), + ]), entries: EntriesSchema, + ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), + }); + +export const PostTrustedAppCreateRequestSchema = { + body: getTrustedAppForOsScheme(), +}; + +export const PutTrustedAppUpdateRequestSchema = { + params: schema.object({ + id: schema.string(), }), + body: getTrustedAppForOsScheme(true), }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.ts new file mode 100644 index 0000000000000..fcde1d44b682d --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.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 { MaybeImmutable, NewTrustedApp, UpdateTrustedApp } from '../../types'; + +const NEW_TRUSTED_APP_KEYS: Array = [ + 'name', + 'effectScope', + 'entries', + 'description', + 'os', + 'version', +]; + +export const toUpdateTrustedApp = ( + trustedApp: MaybeImmutable +): UpdateTrustedApp => { + const trustedAppForUpdate: UpdateTrustedApp = {} as UpdateTrustedApp; + + for (const key of NEW_TRUSTED_APP_KEYS) { + // This should be safe. Its needed due to the inter-dependency on property values (`os` <=> `entries`) + // @ts-expect-error + trustedAppForUpdate[key] = trustedApp[key]; + } + return trustedAppForUpdate; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts similarity index 93% rename from x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts rename to x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index faad639eeacb3..b0828be6af6c5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConditionEntry, ConditionEntryField } from '../types'; +import { ConditionEntry, ConditionEntryField } from '../../types'; const HASH_LENGTHS: readonly number[] = [ 32, // MD5 diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 87268f02a16e1..bed9c2880440a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -62,6 +62,11 @@ type ImmutableMap = ReadonlyMap, Immutable>; type ImmutableSet = ReadonlySet>; type ImmutableObject = { readonly [K in keyof T]: Immutable }; +/** + * Utility type that will return back a union of the given [T]ype and an Immutable version of it + */ +export type MaybeImmutable = T | Immutable; + /** * Stats for related events for a particular node in a resolver graph. */ @@ -375,12 +380,12 @@ export enum HostStatus { * Default state of the host when no host information is present or host information cannot * be retrieved. e.g. API error */ - ERROR = 'error', + UNHEALTHY = 'unhealthy', /** * Host is online as indicated by its checkin status during the last checkin window */ - ONLINE = 'online', + HEALTHY = 'healthy', /** * Host is offline as indicated by its checkin status during the last checkin window @@ -388,9 +393,14 @@ export enum HostStatus { OFFLINE = 'offline', /** - * Host is unenrolling as indicated by its checkin status during the last checkin window + * Host is unenrolling, enrolling or updating as indicated by its checkin status during the last checkin window + */ + UPDATING = 'updating', + + /** + * Host is inactive as indicated by its checkin status during the last checkin window */ - UNENROLLING = 'unenrolling', + INACTIVE = 'inactive', } export enum MetadataQueryStrategyVersions { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index a5c3c1eab52b3..d36958c11d2a1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -9,14 +9,22 @@ import { TypeOf } from '@kbn/config-schema'; import { ApplicationStart } from 'kibana/public'; import { DeleteTrustedAppsRequestSchema, + GetOneTrustedAppRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from '../schema/trusted_apps'; import { OperatingSystem } from './os'; /** API request params for deleting Trusted App entry */ export type DeleteTrustedAppsRequestParams = TypeOf; +export type GetOneTrustedAppRequestParams = TypeOf; + +export interface GetOneTrustedAppResponse { + data: TrustedApp; +} + /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; @@ -39,6 +47,15 @@ export interface PostTrustedAppCreateResponse { data: TrustedApp; } +/** API request params for updating a Trusted App */ +export type PutTrustedAppsRequestParams = TypeOf; + +/** API Request body for Updating a new Trusted App entry */ +export type PutTrustedAppUpdateRequest = TypeOf & + (MacosLinuxConditionEntries | WindowsConditionEntries); + +export type PutTrustedAppUpdateResponse = PostTrustedAppCreateResponse; + export interface GetTrustedAppsSummaryResponse { total: number; windows: number; @@ -76,17 +93,38 @@ export interface WindowsConditionEntries { entries: WindowsConditionEntry[]; } +export interface GlobalEffectScope { + type: 'global'; +} + +export interface PolicyEffectScope { + type: 'policy'; + /** An array of Endpoint Integration Policy UUIDs */ + policies: string[]; +} + +export type EffectScope = GlobalEffectScope | PolicyEffectScope; + /** Type for a new Trusted App Entry */ export type NewTrustedApp = { name: string; description?: string; + effectScope: EffectScope; } & (MacosLinuxConditionEntries | WindowsConditionEntries); +/** An Update to a Trusted App Entry */ +export type UpdateTrustedApp = NewTrustedApp & { + version?: string; +}; + /** A trusted app entry */ export type TrustedApp = NewTrustedApp & { + version: string; id: string; created_at: string; created_by: string; + updated_at: string; + updated_by: string; }; /** diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index c764c31a2d781..19de81cb95c3f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -13,6 +13,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; */ const allowedExperimentalValues = Object.freeze({ fleetServerEnabled: false, + trustedAppsByPolicyEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 22e4179ae7050..79a0351b824e8 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -26,6 +26,19 @@ export const validate = ( return pipe(checked, fold(left, right)); }; +export const validateNonExact = ( + obj: object, + schema: T +): [t.TypeOf | null, string | null] => { + const decoded = schema.decode(obj); + const left = (errors: t.Errors): [T | null, string | null] => [ + null, + formatErrors(errors).join(','), + ]; + const right = (output: T): [T | null, string | null] => [output, null]; + return pipe(decoded, fold(left, right)); +}; + export const validateEither = ( schema: T, obj: A diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index ef9c7f49cb371..e1e78f8e310e1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -16,6 +16,7 @@ import { ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; +import { JSON_LINES } from '../../screens/alerts_details'; import { CUSTOM_RULES_BTN, RISK_SCORE, @@ -50,14 +51,17 @@ import { SCHEDULE_DETAILS, SEVERITY_DETAILS, TAGS_DETAILS, + TIMELINE_FIELD, TIMELINE_TEMPLATE_DETAILS, } from '../../screens/rule_details'; import { + expandFirstAlert, goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; +import { openJsonView, scrollJsonViewToBottom } from '../../tasks/alerts_details'; import { changeRowsPerPageTo300, duplicateFirstRule, @@ -98,7 +102,7 @@ import { import { waitForKibana } from '../../tasks/edit_rule'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; -import { goBackToAllRulesTable } from '../../tasks/rule_details'; +import { addsFieldsToTimeline, goBackToAllRulesTable } from '../../tasks/rule_details'; import { DETECTIONS_URL, RULE_CREATION } from '../../urls/navigation'; @@ -114,11 +118,11 @@ describe('indicator match', () => { before(() => { cleanKibana(); esArchiverLoad('threat_indicator'); - esArchiverLoad('threat_data'); + esArchiverLoad('suspicious_source_event'); }); after(() => { esArchiverUnload('threat_indicator'); - esArchiverUnload('threat_data'); + esArchiverUnload('suspicious_source_event'); }); describe('Creating new indicator match rules', () => { @@ -216,7 +220,7 @@ describe('indicator match', () => { it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getDefineContinueButton().click(); @@ -235,7 +239,7 @@ describe('indicator match', () => { it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'non-existent-value', validColumns: 'indexField', }); @@ -245,7 +249,7 @@ describe('indicator match', () => { it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorAndButton().click(); @@ -267,14 +271,14 @@ describe('indicator match', () => { it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'non-existent-value', validColumns: 'indexField', }); getIndicatorAndButton().click(); fillIndicatorMatchRow({ rowNumber: 2, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'second-non-existent-value', validColumns: 'indexField', }); @@ -305,7 +309,7 @@ describe('indicator match', () => { it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton().click(); @@ -317,7 +321,7 @@ describe('indicator match', () => { it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorAndButton().click(); @@ -330,16 +334,22 @@ describe('indicator match', () => { getIndicatorAndButton().click(); fillIndicatorMatchRow({ rowNumber: 3, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton(2).click(); - getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField(1).should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField(1).should( 'text', newThreatIndicatorRule.indicatorIndexField ); - getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField(2).should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField(2).should( 'text', newThreatIndicatorRule.indicatorIndexField @@ -357,11 +367,14 @@ describe('indicator match', () => { getIndicatorOrButton().click(); fillIndicatorMatchRow({ rowNumber: 2, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton().click(); - getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField().should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField().should( 'text', newThreatIndicatorRule.indicatorIndexField @@ -441,7 +454,7 @@ describe('indicator match', () => { ); getDetails(INDICATOR_MAPPING).should( 'have.text', - `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` + `${newThreatIndicatorRule.indicatorMappingField} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` ); getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); }); @@ -471,6 +484,74 @@ describe('indicator match', () => { }); }); + describe('Enrichment', () => { + const fieldSearch = 'threat.indicator.matched'; + const fields = [ + 'threat.indicator.matched.atomic', + 'threat.indicator.matched.type', + 'threat.indicator.matched.field', + ]; + const expectedFieldsText = [ + newThreatIndicatorRule.atomic, + newThreatIndicatorRule.type, + newThreatIndicatorRule.indicatorMappingField, + ]; + + const expectedEnrichment = [ + { line: 4, text: ' "threat": {' }, + { + line: 3, + text: + ' "indicator": "{\\"first_seen\\":\\"2021-03-10T08:02:14.000Z\\",\\"file\\":{\\"size\\":80280,\\"pe\\":{},\\"type\\":\\"elf\\",\\"hash\\":{\\"sha256\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"tlsh\\":\\"6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE\\",\\"ssdeep\\":\\"1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL\\",\\"md5\\":\\"9b6c3518a91d23ed77504b5416bfb5b3\\"}},\\"type\\":\\"file\\",\\"matched\\":{\\"atomic\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"field\\":\\"myhash.mysha256\\",\\"id\\":\\"84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f\\",\\"index\\":\\"filebeat-7.12.0-2021.03.10-000001\\",\\"type\\":\\"file\\"}}"', + }, + { line: 2, text: ' }' }, + ]; + + before(() => { + cleanKibana(); + esArchiverLoad('threat_indicator'); + esArchiverLoad('suspicious_source_event'); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + createCustomIndicatorRule(newThreatIndicatorRule); + reload(); + }); + + after(() => { + esArchiverUnload('threat_indicator'); + esArchiverUnload('suspicious_source_event'); + }); + + beforeEach(() => { + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + }); + + it('Displays matches on the timeline', () => { + addsFieldsToTimeline(fieldSearch, fields); + + fields.forEach((field, index) => { + cy.get(TIMELINE_FIELD(field)).should('have.text', expectedFieldsText[index]); + }); + }); + + it('Displays enrichment on the JSON view', () => { + expandFirstAlert(); + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + expectedEnrichment.forEach((enrichment) => { + cy.wrap(elements) + .eq(length - enrichment.line) + .should('have.text', enrichment.text); + }); + }); + }); + }); + describe('Duplicates the indicator rule', () => { beforeEach(() => { cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts index 2e0599dfcae21..dee921b0c668a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts @@ -133,7 +133,7 @@ describe('Exceptions modal', () => { closeExceptionBuilderModal(); }); - it.skip('Does not overwrite values of nested entry items', () => { + it('Does not overwrite values of nested entry items', () => { openExceptionModalFromRuleSettings(); cy.get(LOADING_SPINNER).should('not.exist'); @@ -144,13 +144,14 @@ describe('Exceptions modal', () => { // exception item 2 with nested field cy.get(ADD_OR_BTN).click(); - addExceptionEntryFieldValueOfItemX('c', 1, 0); + addExceptionEntryFieldValueOfItemX('agent.name', 1, 0); cy.get(ADD_NESTED_BTN).click(); addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); cy.get(ADD_AND_BTN).click(); addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); // This button will now read `Add non-nested button` - cy.get(ADD_NESTED_BTN).click(); + cy.get(ADD_NESTED_BTN).scrollIntoView(); + cy.get(ADD_NESTED_BTN).focus().click(); addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); // should have only deleted `user.id` @@ -161,7 +162,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); cy.get(EXCEPTION_ITEM_CONTAINER) @@ -178,7 +183,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) .find(FIELD_INPUT) diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index c7ec17d027e80..1fbd9990a76bd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -61,7 +61,7 @@ describe('timeline flyout button', () => { it('the `(+)` button popover menu owns focus', () => { cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); - cy.get(CREATE_NEW_TIMELINE).should('have.focus'); + cy.get(CREATE_NEW_TIMELINE).closest('.euiPanel').should('have.focus'); cy.get('body').type('{esc}'); cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 88dcd998fc06d..68c7796f7ca3b 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -71,8 +71,10 @@ export interface OverrideRule extends CustomRule { export interface ThreatIndicatorRule extends CustomRule { indicatorIndexPattern: string[]; - indicatorMapping: string; + indicatorMappingField: string; indicatorIndexField: string; + type?: string; + atomic?: string; } export interface MachineLearningRule { @@ -299,7 +301,7 @@ export const eqlSequenceRule: CustomRule = { export const newThreatIndicatorRule: ThreatIndicatorRule = { name: 'Threat Indicator Rule Test', description: 'The threat indicator rule description.', - index: ['threat-data-*'], + index: ['suspicious-*'], severity: 'Critical', riskScore: '20', tags: ['test', 'threat'], @@ -309,9 +311,11 @@ export const newThreatIndicatorRule: ThreatIndicatorRule = { note: '# test markdown', runsEvery, lookBack, - indicatorIndexPattern: ['threat-indicator-*'], - indicatorMapping: 'agent.id', - indicatorIndexField: 'agent.threat', + indicatorIndexPattern: ['filebeat-*'], + indicatorMappingField: 'myhash.mysha256', + indicatorIndexField: 'threatintel.indicator.file.hash.sha256', + type: 'file', + atomic: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', timeline, maxSignals: 100, }; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts new file mode 100644 index 0000000000000..417cf73de47f6 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const JSON_CONTENT = '[data-test-subj="jsonView"]'; + +export const JSON_LINES = '.ace_line'; + +export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts index ea274c446c014..1115dfb00914e 100644 --- a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts @@ -5,10 +5,12 @@ * 2.0. */ +export const CLOSE_BTN = '[data-test-subj="close"]'; + export const FIELDS_BROWSER_CATEGORIES_COUNT = '[data-test-subj="categories-count"]'; export const FIELDS_BROWSER_CHECKBOX = (id: string) => { - return `[data-test-subj="field-${id}-checkbox`; + return `[data-test-subj="category-table-container"] [data-test-subj="field-${id}-checkbox"]`; }; export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index f9590b34a0a11..d94be17a0530a 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -53,6 +53,9 @@ export const MACHINE_LEARNING_JOB_STATUS = '[data-test-subj="machineLearningJobS export const MITRE_ATTACK_DETAILS = 'MITRE ATT&CK'; +export const FIELDS_BROWSER_BTN = + '[data-test-subj="events-viewer-panel"] [data-test-subj="show-field-browser"]'; + export const REFRESH_BUTTON = '[data-test-subj="refreshButton"]'; export const RULE_ABOUT_DETAILS_HEADER_TOGGLE = '[data-test-subj="stepAboutDetailsToggle"]'; @@ -92,6 +95,10 @@ export const TIMELINE_TEMPLATE_DETAILS = 'Timeline template'; export const TIMESTAMP_OVERRIDE_DETAILS = 'Timestamp override'; +export const TIMELINE_FIELD = (field: string) => { + return `[data-test-subj="draggable-content-${field}"]`; +}; + export const getDetails = (title: string) => cy.get(DETAILS_TITLE).contains(title).next(DETAILS_DESCRIPTION); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts new file mode 100644 index 0000000000000..1582f35989e2c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.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 { JSON_CONTENT, JSON_VIEW_TAB } from '../screens/alerts_details'; + +export const openJsonView = () => { + cy.get(JSON_VIEW_TAB).click(); +}; + +export const scrollJsonViewToBottom = () => { + cy.get(JSON_CONTENT).click({ force: true }); + cy.get(JSON_CONTENT).type('{pagedown}{pagedown}{pagedown}'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 4bf5508c19aa9..0b051f3a26581 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -45,9 +45,9 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r { entries: [ { - field: rule.indicatorMapping, + field: rule.indicatorMappingField, type: 'mapping', - value: rule.indicatorMapping, + value: rule.indicatorIndexField, }, ], }, @@ -55,13 +55,13 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r threat_query: '*:*', threat_language: 'kuery', threat_filters: [], - threat_index: ['mock*'], + threat_index: rule.indicatorIndexPattern, threat_indicator_path: '', from: 'now-17520h', - index: ['exceptions-*'], + index: rule.index, query: rule.customQuery || '*:*', language: 'kuery', - enabled: false, + enabled: true, }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index b317f158ae614..0c663a95a4bda 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -426,7 +426,7 @@ export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQ export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => { fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern); fillIndicatorMatchRow({ - indexField: rule.indicatorMapping, + indexField: rule.indicatorMappingField, indicatorIndexField: rule.indicatorIndexField, }); getDefineContinueButton().should('exist').click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index 9ee242dcebbe8..72945f557ac1b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -15,8 +15,15 @@ import { FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX, FIELDS_BROWSER_MESSAGE_CHECKBOX, FIELDS_BROWSER_RESET_FIELDS, + FIELDS_BROWSER_CHECKBOX, + CLOSE_BTN, } from '../screens/fields_browser'; -import { KQL_SEARCH_BAR } from '../screens/hosts/main'; + +export const addsFields = (fields: string[]) => { + fields.forEach((field) => { + cy.get(FIELDS_BROWSER_CHECKBOX(field)).click(); + }); +}; export const addsHostGeoCityNameToTimeline = () => { cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check({ @@ -44,7 +51,7 @@ export const clearFieldsBrowser = () => { }; export const closeFieldsBrowser = () => { - cy.get(KQL_SEARCH_BAR).click({ force: true }); + cy.get(CLOSE_BTN).click({ force: true }); }; export const filterFieldsBrowser = (fieldName: string) => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 21a2745395419..37c425c5488bc 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -20,10 +20,12 @@ import { ALERTS_TAB, BACK_TO_RULES, EXCEPTIONS_TAB, + FIELDS_BROWSER_BTN, REFRESH_BUTTON, REMOVE_EXCEPTION_BTN, RULE_SWITCH, } from '../screens/rule_details'; +import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const activatesRule = () => { cy.intercept('PATCH', '/api/detection_engine/rules/_bulk_update').as('bulk_update'); @@ -49,6 +51,13 @@ export const addsException = (exception: Exception) => { cy.get(CONFIRM_BTN).should('not.exist'); }; +export const addsFieldsToTimeline = (search: string, fields: string[]) => { + cy.get(FIELDS_BROWSER_BTN).click(); + filterFieldsBrowser(search); + addsFields(fields); + closeFieldsBrowser(); +}; + export const openExceptionModalFromRuleSettings = () => { cy.get(ADD_EXCEPTIONS_BTN).click(); cy.get(LOADING_SPINNER).should('not.exist'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 59c3fb5876d76..2078744393d94 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -162,6 +162,7 @@ export const closeTimeline = () => { export const createNewTimeline = () => { cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); + cy.wait(300); cy.get(CREATE_NEW_TIMELINE).click(); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index ddb3d98cafca8..4979d70ce2d7b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -107,6 +107,7 @@ const EventDetailsComponent: React.FC = ({ }, { id: EventsViewType.jsonView, + 'data-test-subj': 'jsonViewTab', name: i18n.JSON_VIEW, content: ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index b0ffcb8c5b5b8..7e9e7c40258da 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -115,7 +115,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ onRuleChange, alertStatus, }: AddExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [errorsExist, setErrorExists] = useState(false); const [comment, setComment] = useState(''); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -394,6 +394,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} ({ v4: jest.fn().mockReturnValue('123'), })); -const getEntryNestedWithIdMock = () => ({ - id: '123', - ...getEntryNestedMock(), -}); - -const getEntryExistsWithIdMock = () => ({ - id: '123', - ...getEntryExistsMock(), -}); - -const getEntryMatchWithIdMock = () => ({ - id: '123', - ...getEntryMatchMock(), -}); - -const getEntryMatchAnyWithIdMock = () => ({ - id: '123', - ...getEntryMatchAnyMock(), -}); - const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', title: 'logstash-*', fields, }); -const getMockBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('ip'), - operator: isOperator, - value: 'some value', - nested: undefined, - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('nestedField.child'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, - operator: isOperator, - value: undefined, - nested: 'parent', - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - const mockEndpointFields = [ { name: 'file.path.caseless', @@ -154,1254 +48,22 @@ export const getEndpointField = (name: string) => mockEndpointFields.find((field) => field.name === name) as IFieldType; describe('Exception builder helpers', () => { - describe('#getCorrespondingKeywordField', () => { - test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw.text', - }); + describe('#filterIndexPatterns', () => { + test('it returns index patterns without filtering if list type is "detection"', () => { + const mockIndexPatterns = getMockIndexPattern(); + const output = filterIndexPatterns(mockIndexPatterns, 'detection'); - expect(output).toEqual(getField('machine.os.raw')); + expect(output).toEqual(mockIndexPatterns); }); - test('it returns undefined if "selectedFieldIsTextType" is false', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is empty string', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: '', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is undefined', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: undefined, - }); - - expect(output).toEqual(undefined); - }); - }); - - describe('#getFilteredIndexPatterns', () => { - describe('list type detections', () => { - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'child' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [...fields], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - - describe('list type endpoint', () => { - let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - - beforeAll(() => { - payloadIndexPattern = { - ...payloadIndexPattern, - fields: [...payloadIndexPattern.fields, ...mockEndpointFields], - }; - }); - - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - id: '123', - field: getEndpointField('file.Ext.code_signature.status'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: 'status' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: { - ...getEndpointField('file.Ext.code_signature.status'), - name: 'file.Ext.code_signature', - esTypes: ['nested'], - }, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['nested'], - name: 'file.Ext.code_signature', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'file.Ext.code_signature', - }, - }, - type: 'string', - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [getEndpointField('file.Ext.code_signature.status')], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['keyword'], - name: 'file.path.caseless', - readFromDocValues: false, - scripted: false, - searchable: true, - type: 'string', - }, - { - name: 'file.Ext.code_signature.status', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: true, - aggregatable: false, - readFromDocValues: false, - subType: { nested: { path: 'file.Ext.code_signature' } }, - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - }); - - describe('#getFormattedBuilderEntry', () => { - test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { - const payloadIndexPattern: IIndexPattern = { + test('it returns filtered index patterns if list type is "endpoint"', () => { + const mockIndexPatterns = { ...getMockIndexPattern(), - fields: [ - ...fields, - { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - ], - }; - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'machine.os.raw.text', - value: 'some os', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some os', - correspondingKeywordField: getField('machine.os.raw'), - }; - expect(output).toEqual(expected); - }); - - test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - payloadParent, - 1 - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [{ ...payloadItem }], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - - test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'ip', - value: 'some ip', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#isEntryNested', () => { - test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchWithIdMock(); - const output = isEntryNested(payload); - const expected = false; - expect(output).toEqual(expected); - }); - - test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedWithIdMock(); - const output = isEntryNested(payload); - const expected = true; - expect(output).toEqual(expected); - }); - }); - - describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: undefined, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when no nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, - ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: true, - count: 0, - esTypes: ['keyword'], - name: 'extension', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'string', - }, - nested: undefined, - operator: isOneOfOperator, - parent: undefined, - value: ['some extension'], - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...payloadParent }, - ]; - - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: false, - esTypes: ['nested'], - name: 'nestedField', - searchable: false, - type: 'string', - }, - nested: 'parent', - operator: isOperator, - parent: undefined, - value: undefined, - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some host name', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - }); - - describe('#getUpdatedEntriesOnDelete', () => { - test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: ENTRIES_WITH_IDS, - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - id: '123', - field: 'some.not.nested.field', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes nested entry of "entryIndex" with corresponding parent index', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [], - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryFromOperator', () => { - test('it returns current value when switching from "is" to "is not"', () => { - const payloadOperator: OperatorOption = isNotOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not" to "is"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is one of" to "is not one of"', () => { - const payloadOperator: OperatorOption = isNotOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not one of" to "is one of"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match_any"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], + fields: [...fields, ...mockEndpointFields], }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "exists" to "does not exist"', () => { - const payloadOperator: OperatorOption = doesNotExistOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: existsOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "does not exist" to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: doesNotExistOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "list"', () => { - const payloadOperator: OperatorOption = isInListOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryList & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'list', - list: { id: '', type: 'ip' }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getOperatorOptions', () => { - test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" if no field selected', () => { - const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); + const output = filterIndexPatterns(mockIndexPatterns, 'endpoint'); - test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', true); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [isOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns all operator options if "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = EXCEPTION_OPERATORS; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [ - isOperator, - isNotOperator, - existsOperator, - doesNotExistOperator, - ]; - expect(output).toEqual(expected); - }); - - test('it returns list operators if specified to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, true); - expect(output).toEqual(EXCEPTION_OPERATORS); - }); - - test('it does not return list operators if specified not to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, false); - expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); - }); - }); - - describe('#getEntryOnFieldChange', () => { - test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [ - { ...getEntryMatchWithIdMock(), field: 'child' }, - getEntryMatchAnyWithIdMock(), - ], - }, - parentIndex: 0, - }, - }; - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - getEntryMatchAnyWithIdMock(), - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns field of type "match" with updated field if not a nested entry', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadIFieldType: IFieldType = getField('ip'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnOperatorChange', () => { - test('it returns updated subentry preserving its value when entry is not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'some value', - operator: 'excluded', - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: [], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.EXCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchAnyChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - field: undefined, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - field: undefined, - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnListChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - field: undefined, - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); + expect(output).toEqual({ ...getMockIndexPattern(), fields: [...mockEndpointFields] }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 8afdbce68c69a..0ad9814484a2f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -7,616 +7,21 @@ import uuid from 'uuid'; -import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; -import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; -import { - Entry, - OperatorTypeEnum, - EntryNested, - ExceptionListType, - entriesList, - ListSchema, - OperatorEnum, -} from '../../../../lists_plugin_deps'; -import { - isOperator, - existsOperator, - isOneOfOperator, - EXCEPTION_OPERATORS, - EXCEPTION_OPERATORS_SANS_LISTS, - isNotOperator, - doesNotExistOperator, -} from '../../autocomplete/operators'; -import { OperatorOption } from '../../autocomplete/types'; -import { - BuilderEntry, - FormattedBuilderEntry, - ExceptionsBuilderExceptionItem, - EmptyEntry, - EmptyNestedEntry, -} from '../types'; -import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { OperatorTypeEnum, ExceptionListType, OperatorEnum } from '../../../../lists_plugin_deps'; +import { ExceptionsBuilderExceptionItem, EmptyEntry, EmptyNestedEntry } from '../types'; import exceptionableFields from '../exceptionable_fields.json'; -/** - * Returns filtered index patterns based on the field - if a user selects to - * add nested entry, should only show nested fields, if item is the parent - * field of a nested entry, we only display the parent field - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * set to add a nested field - */ -export const getFilteredIndexPatterns = ( +export const filterIndexPatterns = ( patterns: IIndexPattern, - item: FormattedBuilderEntry, type: ExceptionListType ): IIndexPattern => { - const indexPatterns = { - ...patterns, - fields: patterns.fields.filter(({ name }) => - type === 'endpoint' ? exceptionableFields.includes(name) : true - ), - }; - - if (item.nested === 'child' && item.parent != null) { - // when user has selected a nested entry, only fields with the common parent are shown - return { - ...indexPatterns, - fields: indexPatterns.fields - .filter((indexField) => { - const fieldHasCommonParentPath = - indexField.subType != null && - indexField.subType.nested != null && - item.parent != null && - indexField.subType.nested.path === item.parent.parent.field; - - return fieldHasCommonParentPath; - }) - .map((f) => { - const fieldNameWithoutParentPath = f.name.split('.').slice(-1)[0]; - return { ...f, name: fieldNameWithoutParentPath }; - }), - }; - } else if (item.nested === 'parent' && item.field != null) { - // when user has selected a nested entry, right above it we show the common parent - return { ...indexPatterns, fields: [item.field] }; - } else if (item.nested === 'parent' && item.field == null) { - // when user selects to add a nested entry, only nested fields are shown as options - return { - ...indexPatterns, - fields: indexPatterns.fields.filter( - (field) => field.subType != null && field.subType.nested != null - ), - }; - } else { - return indexPatterns; - } -}; - -/** - * Fields of type 'text' do not generate autocomplete values, we want - * to find it's corresponding keyword type (if available) which does - * generate autocomplete values - * - * @param fields IFieldType fields - * @param selectedField the field name that was selected - * @param isTextType we only want a corresponding keyword field if - * the selected field is of type 'text' - * - */ -export const getCorrespondingKeywordField = ({ - fields, - selectedField, -}: { - fields: IFieldType[]; - selectedField: string | undefined; -}): IFieldType | undefined => { - const selectedFieldBits = - selectedField != null && selectedField !== '' ? selectedField.split('.') : []; - const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; - - if (selectedFieldIsTextType && selectedFieldBits.length > 0) { - const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); - const [foundKeywordField] = fields.filter( - ({ name }) => keywordField !== '' && keywordField === name - ); - return foundKeywordField; - } - - return undefined; -}; - -/** - * Formats the entry into one that is easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * @param itemIndex entry index - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntry = ( - indexPattern: IIndexPattern, - item: BuilderEntry, - itemIndex: number, - parent: EntryNested | undefined, - parentIndex: number | undefined -): FormattedBuilderEntry => { - const { fields } = indexPattern; - const field = parent != null ? `${parent.field}.${item.field}` : item.field; - const [foundField] = fields.filter(({ name }) => field != null && field === name); - const correspondingKeywordField = getCorrespondingKeywordField({ - fields, - selectedField: field, - }); - - if (parent != null && parentIndex != null) { - return { - field: - foundField != null - ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } - : foundField, - correspondingKeywordField, - id: item.id ?? `${itemIndex}`, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: 'child', - parent: { parent, parentIndex }, - entryIndex: itemIndex, - }; - } else { - return { - field: foundField, - id: item.id ?? `${itemIndex}`, - correspondingKeywordField, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: undefined, - parent: undefined, - entryIndex: itemIndex, - }; - } -}; - -export const isEntryNested = (item: BuilderEntry): item is EntryNested => { - return (item as EntryNested).entries != null; -}; - -/** - * Formats the entries to be easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param entries exception item entries - * @param addNested boolean noting whether or not UI is currently - * set to add a nested field - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntries = ( - indexPattern: IIndexPattern, - entries: BuilderEntry[], - parent?: EntryNested, - parentIndex?: number -): FormattedBuilderEntry[] => { - return entries.reduce((acc, item, index) => { - const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; - if (item.type !== 'nested' && !isNewNestedEntry) { - const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( - indexPattern, - item, - index, - parent, - parentIndex - ); - return [...acc, newItemEntry]; - } else { - const parentEntry: FormattedBuilderEntry = { - operator: isOperator, - id: item.id ?? `${index}`, - nested: 'parent', - field: isNewNestedEntry - ? undefined - : { - name: item.field ?? '', - aggregatable: false, - searchable: false, - type: 'string', - esTypes: ['nested'], - }, - value: undefined, - entryIndex: index, - parent: undefined, - correspondingKeywordField: undefined, - }; - - // User has selected to add a nested field, but not yet selected the field - if (isNewNestedEntry) { - return [...acc, parentEntry]; + return type === 'endpoint' + ? { + ...patterns, + fields: patterns.fields.filter(({ name }) => exceptionableFields.includes(name)), } - - if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); - - return [...acc, parentEntry, ...nestedItems]; - } - - return [...acc]; - } - }, []); -}; - -/** - * Determines whether an entire entry, exception item, or entry within a nested - * entry needs to be removed - * - * @param exceptionItem - * @param entryIndex index of given entry, for nested entries, this will correspond - * to their parent index - * @param nestedEntryIndex index of nested entry - * - */ -export const getUpdatedEntriesOnDelete = ( - exceptionItem: ExceptionsBuilderExceptionItem, - entryIndex: number, - nestedParentIndex: number | null -): ExceptionsBuilderExceptionItem => { - const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; - - if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries = [ - ...itemOfInterest.entries.slice(0, entryIndex), - ...itemOfInterest.entries.slice(entryIndex + 1), - ]; - - if (updatedEntryEntries.length === 0) { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } else { - const { field } = itemOfInterest; - const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { - field, - id: itemOfInterest.id ?? `${entryIndex}`, - type: OperatorTypeEnum.NESTED, - entries: updatedEntryEntries, - }; - - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - updatedItemOfInterest, - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } - } else { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ], - }; - } -}; - -/** - * On operator change, determines whether value needs to be cleared or not - * - * @param field - * @param selectedOperator - * @param currentEntry - * - */ -export const getEntryFromOperator = ( - selectedOperator: OperatorOption, - currentEntry: FormattedBuilderEntry -): Entry & { id?: string } => { - const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; - const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; - switch (selectedOperator.type) { - case 'match': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH, - operator: selectedOperator.operator, - value: - isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', - }; - case 'match_any': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH_ANY, - operator: selectedOperator.operator, - value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], - }; - case 'list': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.LIST, - operator: selectedOperator.operator, - list: { id: '', type: 'ip' }, - }; - default: - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.EXISTS, - operator: selectedOperator.operator, - }; - } -}; - -/** - * Determines which operators to make available - * - * @param item - * @param listType - * @param isBoolean - * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators - */ -export const getOperatorOptions = ( - item: FormattedBuilderEntry, - listType: ExceptionListType, - isBoolean: boolean, - includeValueListOperators = true -): OperatorOption[] => { - if (item.nested === 'parent' || item.field == null) { - return [isOperator]; - } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { - return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; - } else if (item.nested != null && listType === 'detection') { - return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; - } else { - return isBoolean - ? [isOperator, isNotOperator, existsOperator, doesNotExistOperator] - : includeValueListOperators - ? EXCEPTION_OPERATORS - : EXCEPTION_OPERATORS_SANS_LISTS; - } -}; - -/** - * Determines proper entry update when user selects new field - * - * @param item - current exception item entry values - * @param newField - newly selected field - * - */ -export const getEntryOnFieldChange = ( - item: FormattedBuilderEntry, - newField: IFieldType -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, nested } = item; - const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; - - if (nested === 'parent') { - // For nested entries, when user first selects to add a nested - // entry, they first see a row similar to what is shown for when - // a user selects "exists", as soon as they make a selection - // we can now identify the 'parent' and 'child' this is where - // we first convert the entry into type "nested" - const newParentFieldValue = - newField.subType != null && newField.subType.nested != null - ? newField.subType.nested.path - : ''; - - return { - updatedEntry: { - id: item.id, - field: newParentFieldValue, - type: OperatorTypeEnum.NESTED, - entries: [ - addIdToItem({ - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }), - ], - }, - index: entryIndex, - }; - } else if (nested === 'child' && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: newField != null ? newField.name : '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user selects new operator - * - * @param item - current exception item entry values - * @param newOperator - newly selected operator - * - */ -export const getEntryOnOperatorChange = ( - item: FormattedBuilderEntry, - newOperator: OperatorOption -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, field, nested } = item; - const newEntry = getEntryFromOperator(newOperator, item); - - if (!entriesList.is(newEntry) && nested != null && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - ...newEntry, - field: field != null ? field.name.split('.').slice(-1)[0] : '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { updatedEntry: newEntry, index: entryIndex }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchChange = ( - item: FormattedBuilderEntry, - newField: string -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match_any" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchAnyChange = ( - item: FormattedBuilderEntry, - newField: string[] -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "list" - * - * @param item - current exception item entry values - * @param newField - newly selected list - * - */ -export const getEntryOnListChange = ( - item: FormattedBuilderEntry, - newField: ListSchema -): { updatedEntry: BuilderEntry; index: number } => { - const { entryIndex, field, operator } = item; - const { id, type } = newField; - - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.LIST, - operator: operator.operator, - list: { id, type }, - }, - index: entryIndex, - }; + : patterns; }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx index 4d0e3306e3315..2863b92ca68ab 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx @@ -17,37 +17,26 @@ import { import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; -import { useKibana } from '../../../../common/lib/kibana'; import { getEmptyValue } from '../../empty_value'; import { ExceptionBuilderComponent } from './'; import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; +import { coreMock } from 'src/core/public/mocks'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece', }, }); - -jest.mock('../../../../common/lib/kibana'); +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('ExceptionBuilderComponent', () => { let wrapper: ReactWrapper; const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - afterEach(() => { getValueSuggestionsMock.mockClear(); jest.clearAllMocks(); @@ -58,6 +47,8 @@ describe('ExceptionBuilderComponent', () => { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount(
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 954a75fc370bd..e33478ad99660 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -98,7 +98,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ onConfirm, onRuleChange, }: EditExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [comment, setComment] = useState(''); const [errorsExist, setErrorExists] = useState(false); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -313,6 +313,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} > = memo( ItemDetailsAction.displayName = 'ItemDetailsAction'; -export const ItemDetailsCard: FC = memo(({ children }) => { - const childElements = useMemo( - () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), - [children] - ); - - return ( - - - - - {childElements.get(ItemDetailsPropertySummary)} - - - - - -
{childElements.get(OTHER_NODES)}
-
- {childElements.has(ItemDetailsAction) && ( - - - {childElements.get(ItemDetailsAction)?.map((action, index) => ( - - {action} - - ))} - +export type ItemDetailsCardProps = PropsWithChildren<{ + 'data-test-subj'?: string; +}>; +export const ItemDetailsCard = memo( + ({ children, 'data-test-subj': dataTestSubj }) => { + const childElements = useMemo( + () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), + [children] + ); + + return ( + + + + + {childElements.get(ItemDetailsPropertySummary)} + + + + + +
{childElements.get(OTHER_NODES)}
- )} -
-
-
-
- ); -}); + {childElements.has(ItemDetailsAction) && ( + + + {childElements.get(ItemDetailsAction)?.map((action, index) => ( + + {action} + + ))} + + + )} +
+
+
+
+ ); + } +); ItemDetailsCard.displayName = 'ItemDetailsCard'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/anomaly_score.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/anomaly_score.test.tsx.snap index 0abb94f6e92ff..19f65b8d287e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/anomaly_score.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/anomaly_score.test.tsx.snap @@ -93,7 +93,7 @@ exports[`anomaly_scores renders correctly against snapshot 1`] = ` id="anomaly-score-popover" isOpen={false} onClick={[Function]} - ownFocus={false} + ownFocus={true} panelPaddingSize="m" repositionOnScroll={true} > diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index fd2beda169482..3440e5f4488c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -101,7 +101,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta hasArrow={true} id="customizablePagination" isOpen={false} - ownFocus={false} + ownFocus={true} panelPaddingSize="none" repositionOnScroll={true} > diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts new file mode 100644 index 0000000000000..2ac5948641d7d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { useIsExperimentalFeatureEnabled } from './use_experimental_features'; + +jest.mock('react-redux'); +const useSelectorMock = useSelector as jest.Mock; +const mockAppState = { + app: { + enableExperimental: { + featureA: true, + featureB: false, + }, + }, +}; + +describe('useExperimentalFeatures', () => { + beforeEach(() => { + useSelectorMock.mockImplementation((cb) => { + return cb(mockAppState); + }); + }); + afterEach(() => { + useSelectorMock.mockClear(); + }); + it('throws an error when unexisting feature', async () => { + expect(() => + useIsExperimentalFeatureEnabled('unexistingFeature' as keyof ExperimentalFeatures) + ).toThrowError(); + }); + it('returns true when existing feature and is enabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureA' as keyof ExperimentalFeatures); + + expect(result).toBeTruthy(); + }); + it('returns false when existing feature and is disabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureB' as keyof ExperimentalFeatures); + + expect(result).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts new file mode 100644 index 0000000000000..247b7624914cf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { State } from '../../common/store'; +import { + ExperimentalFeatures, + getExperimentalAllowedValues, +} from '../../../common/experimental_features'; + +const allowedExperimentalValues = getExperimentalAllowedValues(); + +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { + return useSelector(({ app: { enableExperimental } }: State) => { + if (!enableExperimental || !(feature in enableExperimental)) { + throw new Error( + `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( + ', ' + )}` + ); + } + return enableExperimental[feature]; + }); +}; diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 38ecedc0c7ba7..5a252e4aa48f2 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { Note } from '../../lib/note'; export type ErrorState = ErrorModel; @@ -24,4 +25,5 @@ export type ErrorModel = Error[]; export interface AppModel { notesById: NotesById; errors: ErrorState; + enableExperimental?: ExperimentalFeatures; } diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 9a2289765e85d..d2808a02c8621 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { parseExperimentalConfigValue } from '../../..//common/experimental_features'; import { createInitialState } from './reducer'; jest.mock('../lib/kibana', () => ({ @@ -22,6 +23,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: ['auditbeat-*', 'filebeat'], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); @@ -35,6 +37,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: [], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 27fddafc3781f..c2ef2563fe63e 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -21,6 +21,7 @@ import { ManagementPluginReducer } from '../../management'; import { State } from './types'; import { AppAction } from './actions'; import { KibanaIndexPatterns } from './sourcerer/model'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; export type SubPluginsInitReducer = HostsPluginReducer & NetworkPluginReducer & @@ -36,14 +37,16 @@ export const createInitialState = ( kibanaIndexPatterns, configIndexPatterns, signalIndexName, + enableExperimental, }: { kibanaIndexPatterns: KibanaIndexPatterns; configIndexPatterns: string[]; signalIndexName: string | null; + enableExperimental: ExperimentalFeatures; } ): PreloadedState => { const preloadedState: PreloadedState = { - app: initialAppState, + app: { ...initialAppState, enableExperimental }, dragAndDrop: initialDragAndDropState, ...pluginsInitState, inputs: createInitialInputsState(), diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index c1d54192c86b1..7616dfccddaff 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -9,6 +9,10 @@ import { Dispatch } from 'redux'; import { State, ImmutableMiddlewareFactory } from './types'; import { AppAction } from './actions'; +interface WaitForActionOptions { + validate?: (action: A extends { type: T } ? A : never) => boolean; +} + /** * Utilities for testing Redux middleware */ @@ -21,7 +25,10 @@ export interface MiddlewareActionSpyHelper(actionType: T) => Promise; + waitForAction: ( + actionType: T, + options?: WaitForActionOptions + ) => Promise; /** * A property holding the information around the calls that were processed by the internal * `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked @@ -78,7 +85,7 @@ export const createSpyMiddleware = < let spyDispatch: jest.Mock>; return { - waitForAction: async (actionType) => { + waitForAction: async (actionType, options = {}) => { type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used @@ -87,6 +94,10 @@ export const createSpyMiddleware = < return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { if (action.type === actionType) { + if (options.validate && !options.validate(action as ResolvedAction)) { + return; + } + watchers.delete(watch); clearTimeout(timeout); resolve(action as ResolvedAction); diff --git a/x-pack/plugins/security_solution/public/common/types.ts b/x-pack/plugins/security_solution/public/common/types.ts index 68346847eb8d1..f1a7cdc8abc60 100644 --- a/x-pack/plugins/security_solution/public/common/types.ts +++ b/x-pack/plugins/security_solution/public/common/types.ts @@ -10,3 +10,7 @@ export interface ServerApiError { error: string; message: string; } + +export interface SecuritySolutionUiConfigType { + enableExperimental: string[]; +} diff --git a/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts b/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts index 3962d6c946a48..e63a2b20a5ad5 100644 --- a/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts +++ b/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { mount } from 'enzyme'; type WrapperOf any> = (...args: Parameters) => ReturnType; // eslint-disable-line diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx index 5dbe1f1cef5be..fb71c6c4b0350 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx @@ -94,7 +94,26 @@ describe('RuleActionsField', () => { `); }); - it('if we do NOT have an error on case action creation, we are supporting case connector', () => { + // sub-cases-enabled: remove this once the sub cases and connector feature is completed + // https://github.com/elastic/kibana/issues/94115 + it('should not contain the case connector as a supported action', () => { + expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "enabledInConfig": false, + "enabledInLicense": true, + "id": ".jira", + "minimumLicenseRequired": "gold", + "name": "My Jira", + }, + ] + `); + }); + + // sub-cases-enabled: unskip after sub cases and the case connector is supported + // https://github.com/elastic/kibana/issues/94115 + it.skip('if we do NOT have an error on case action creation, we are supporting case connector', () => { expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(` Array [ Object { diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index cbcc054e7c6a9..bf754720f314b 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -108,6 +108,7 @@ const normalizeTrustedAppsPageLocation = ( : {}), ...(!isDefaultOrMissing(location.view_type, 'grid') ? { view_type: location.view_type } : {}), ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), }; } else { return {}; @@ -147,11 +148,20 @@ export const extractListPaginationParams = (query: querystring.ParsedUrlQuery) = export const extractTrustedAppsListPageLocation = ( query: querystring.ParsedUrlQuery -): TrustedAppsListPageLocation => ({ - ...extractListPaginationParams(query), - view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', - show: extractFirstParamValue(query, 'show') === 'create' ? 'create' : undefined, -}); +): TrustedAppsListPageLocation => { + const showParamValue = extractFirstParamValue( + query, + 'show' + ) as TrustedAppsListPageLocation['show']; + + return { + ...extractListPaginationParams(query), + view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', + show: + showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, + id: extractFirstParamValue(query, 'id'), + }; +}; export const getTrustedAppsListPath = (location?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index 404ee0cd4aa2c..40b843a676d9c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -54,7 +54,7 @@ export const mockEndpointResultList: (options?: { for (let index = 0; index < actualCountToReturn; index++) { hosts.push({ metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: queryStrategyVersion, }); } @@ -74,7 +74,7 @@ export const mockEndpointResultList: (options?: { export const mockEndpointDetailsApiResult = (): HostInfo => { return { metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 17ce24e7cda7f..eec4de6400145 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -231,7 +231,7 @@ export const showView: (state: EndpointState) => 'policy_response' | 'details' = export const hostStatusInfo: (state: Immutable) => HostStatus = createSelector( (state) => state.hostStatus, (hostStatus) => { - return hostStatus ? hostStatus : HostStatus.ERROR; + return hostStatus ? hostStatus : HostStatus.UNHEALTHY; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index eb3e534ba427f..c97e097ea9b72 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -8,7 +8,6 @@ import styled from 'styled-components'; import { EuiDescriptionList, - EuiHealth, EuiHorizontalRule, EuiListGroup, EuiListGroupItem, @@ -17,6 +16,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, + EuiSpacer, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,11 +26,7 @@ import { HostInfo, HostMetadata, HostStatus } from '../../../../../../common/end import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; -import { - POLICY_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_BADGE_COLOR, - HOST_STATUS_TO_HEALTH_COLOR, -} from '../host_constants'; +import { POLICY_STATUS_TO_BADGE_COLOR, HOST_STATUS_TO_BADGE_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; @@ -48,17 +44,6 @@ const HostIds = styled(EuiListGroupItem)` } `; -const LinkToExternalApp = styled.div` - margin-top: ${(props) => props.theme.eui.ruleMargins.marginMedium}; - .linkToAppIcon { - margin-right: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - vertical-align: top; - } - .linkToAppPopoutIcon { - margin-left: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - } -`; - const openReassignFlyoutSearch = '?openReassignFlyout=true'; export const EndpointDetails = memo( @@ -80,7 +65,7 @@ export const EndpointDetails = memo( const queryParams = useEndpointSelector(uiQueryParams); const policyStatus = useEndpointSelector( policyResponseStatus - ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; + ) as keyof typeof POLICY_STATUS_TO_BADGE_COLOR; const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { @@ -89,32 +74,37 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.os', { defaultMessage: 'OS', }), - description: details.host.os.full, + description: {details.host.os.full}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.agentStatus', { defaultMessage: 'Agent Status', }), description: ( - - + ), }, { title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { defaultMessage: 'Last Seen', }), - description: , + description: ( + + {' '} + + + ), }, ]; }, [details, hostStatus]); @@ -169,12 +159,14 @@ export const EndpointDetails = memo( description: ( - - {details.Endpoint.policy.applied.name} - + + + {details.Endpoint.policy.applied.name} + + {details.Endpoint.policy.applied.endpoint_policy_version && ( @@ -241,9 +233,11 @@ export const EndpointDetails = memo( }), description: ( - {details.host.ip.map((ip: string, index: number) => ( - - ))} + + {details.host.ip.map((ip: string, index: number) => ( + + ))} + ), }, @@ -251,13 +245,13 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { defaultMessage: 'Hostname', }), - description: details.host.hostname, + description: {details.host.hostname}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { defaultMessage: 'Endpoint Version', }), - description: details.agent.version, + description: {details.agent.version}, }, ]; }, [details.agent.version, details.host.hostname, details.host.ip]); @@ -275,22 +269,36 @@ export const EndpointDetails = memo( listItems={detailsResultsPolicy} data-test-subj="endpointDetailsPolicyList" /> - - + + - - - - - + + + + + + + + + + + + + ({ - [HostStatus.ERROR]: 'danger', - [HostStatus.ONLINE]: 'success', - [HostStatus.OFFLINE]: 'subdued', - [HostStatus.UNENROLLING]: 'warning', + [HostStatus.HEALTHY]: 'secondary', + [HostStatus.UNHEALTHY]: 'warning', + [HostStatus.UPDATING]: 'primary', + [HostStatus.OFFLINE]: 'default', + [HostStatus.INACTIVE]: 'default', }); export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< { [key in keyof typeof HostPolicyResponseActionStatus]: string } >({ - success: 'success', + success: 'secondary', warning: 'warning', failure: 'danger', - unsupported: 'subdued', + unsupported: 'default', }); export const POLICY_STATUS_TO_BADGE_COLOR = Object.freeze< diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 79e91fdeb813a..17ebff603ccfb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -25,7 +25,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; -import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; +import { POLICY_STATUS_TO_TEXT } from './host_constants'; import { mockPolicyResultList } from '../../policy/store/test_mock_utils'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; @@ -232,9 +232,10 @@ describe('when on the list page', () => { > = []; let firstPolicyID: string; let firstPolicyRev: number; + beforeEach(() => { reactTestingLibrary.act(() => { - const mockedEndpointData = mockEndpointResultList({ total: 4 }); + const mockedEndpointData = mockEndpointResultList({ total: 5 }); const hostListData = mockedEndpointData.hosts; const queryStrategyVersion = mockedEndpointData.query_strategy_version; @@ -259,9 +260,9 @@ describe('when on the list page', () => { }; [ - { status: HostStatus.ERROR, policy: (p: Policy) => p }, + { status: HostStatus.UNHEALTHY, policy: (p: Policy) => p }, { - status: HostStatus.ONLINE, + status: HostStatus.HEALTHY, policy: (p: Policy) => { p.endpoint.id = 'xyz'; // represents change in endpoint policy assignment p.endpoint.revision = 1; @@ -276,7 +277,14 @@ describe('when on the list page', () => { }, }, { - status: HostStatus.UNENROLLING, + status: HostStatus.UPDATING, + policy: (p: Policy) => { + p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet + return p; + }, + }, + { + status: HostStatus.INACTIVE, policy: (p: Policy) => { p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet return p; @@ -317,7 +325,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const rows = await renderResult.findAllByRole('row'); - expect(rows).toHaveLength(5); + expect(rows).toHaveLength(6); }); it('should show total', async () => { const renderResult = render(); @@ -325,7 +333,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const total = await renderResult.findByTestId('endpointListTableTotal'); - expect(total.textContent).toEqual('4 Hosts'); + expect(total.textContent).toEqual('5 Hosts'); }); it('should display correct status', async () => { const renderResult = render(); @@ -334,23 +342,30 @@ describe('when on the list page', () => { }); const hostStatuses = await renderResult.findAllByTestId('rowHostStatus'); - expect(hostStatuses[0].textContent).toEqual('Error'); - expect(hostStatuses[0].querySelector('[data-euiicon-type][color="danger"]')).not.toBeNull(); + expect(hostStatuses[0].textContent).toEqual('Unhealthy'); + expect(hostStatuses[0].getAttribute('style')).toMatch( + /background-color\: rgb\(241\, 216\, 111\)\;/ + ); - expect(hostStatuses[1].textContent).toEqual('Online'); - expect( - hostStatuses[1].querySelector('[data-euiicon-type][color="success"]') - ).not.toBeNull(); + expect(hostStatuses[1].textContent).toEqual('Healthy'); + expect(hostStatuses[1].getAttribute('style')).toMatch( + /background-color\: rgb\(109\, 204\, 177\)\;/ + ); expect(hostStatuses[2].textContent).toEqual('Offline'); - expect( - hostStatuses[2].querySelector('[data-euiicon-type][color="subdued"]') - ).not.toBeNull(); + expect(hostStatuses[2].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); - expect(hostStatuses[3].textContent).toEqual('Unenrolling'); - expect( - hostStatuses[3].querySelector('[data-euiicon-type][color="warning"]') - ).not.toBeNull(); + expect(hostStatuses[3].textContent).toEqual('Updating'); + expect(hostStatuses[3].getAttribute('style')).toMatch( + /background-color\: rgb\(121\, 170\, 217\)\;/ + ); + + expect(hostStatuses[4].textContent).toEqual('Inactive'); + expect(hostStatuses[4].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); }); it('should display correct policy status', async () => { @@ -361,14 +376,18 @@ describe('when on the list page', () => { const policyStatuses = await renderResult.findAllByTestId('rowPolicyStatus'); policyStatuses.forEach((status, index) => { + const policyStatusToRGBColor: Array<[string, string]> = [ + ['Success', 'background-color: rgb(109, 204, 177);'], + ['Warning', 'background-color: rgb(241, 216, 111);'], + ['Failure', 'background-color: rgb(255, 126, 98);'], + ['Unsupported', 'background-color: rgb(211, 218, 230);'], + ]; + const policyStatusStyleMap: ReadonlyMap = new Map( + policyStatusToRGBColor + ); + const expectedStatusColor: string = policyStatusStyleMap.get(status.textContent!) ?? ''; expect(status.textContent).toEqual(POLICY_STATUS_TO_TEXT[generatedPolicyStatuses[index]]); - expect( - status.querySelector( - `[data-euiicon-type][color=${ - POLICY_STATUS_TO_HEALTH_COLOR[generatedPolicyStatuses[index]] - }]` - ) - ).not.toBeNull(); + expect(status.getAttribute('style')).toMatch(expectedStatusColor); }); }); @@ -378,7 +397,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const outOfDates = await renderResult.findAllByTestId('rowPolicyOutOfDate'); - expect(outOfDates).toHaveLength(3); + expect(outOfDates).toHaveLength(4); outOfDates.forEach((item, index) => { expect(item.textContent).toEqual('Out-of-date'); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c4c27bd493950..d28bf6b38fd31 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import React, { useMemo, useCallback, memo, useState } from 'react'; +import React, { useMemo, useCallback, memo, useState, useContext } from 'react'; import { EuiHorizontalRule, EuiBasicTable, EuiBasicTableColumn, EuiText, EuiLink, - EuiHealth, + EuiBadge, EuiToolTip, EuiSelectableProps, EuiSuperDatePicker, @@ -33,13 +33,14 @@ import { createStructuredSelector } from 'reselect'; import { useDispatch } from 'react-redux'; import { EuiContextMenuItemProps } from '@elastic/eui/src/components/context_menu/context_menu_item'; import { NavigateToAppOptions } from 'kibana/public'; +import { ThemeContext } from 'styled-components'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; import { isPolicyOutOfDate } from '../utils'; import { - HOST_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_HEALTH_COLOR, + HOST_STATUS_TO_BADGE_COLOR, + POLICY_STATUS_TO_BADGE_COLOR, POLICY_STATUS_TO_TEXT, } from './host_constants'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; @@ -72,11 +73,24 @@ const EndpointListNavLink = memo<{ name: string; href: string; route: string; + isBadge?: boolean; dataTestSubj: string; -}>(({ name, href, route, dataTestSubj }) => { +}>(({ name, href, route, isBadge = false, dataTestSubj }) => { const clickHandler = useNavigateByRouterEventHandler(route); + const theme = useContext(ThemeContext); - return ( + return isBadge ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {name} + + ) : ( // eslint-disable-next-line @elastic/eui/href-or-on-click { // eslint-disable-next-line react/display-name render: (hostStatus: HostInfo['host_status']) => { return ( - - + ); }, }, @@ -375,8 +389,8 @@ export const EndpointList = () => { }); const toRouteUrl = formatUrl(toRoutePath); return ( - @@ -384,9 +398,10 @@ export const EndpointList = () => { name={POLICY_STATUS_TO_TEXT[policy.status]} href={toRouteUrl} route={toRoutePath} + isBadge dataTestSubj="policyStatusCellLink" /> - + ); }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 578043f4321e9..5f572251daeda 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -10,7 +10,9 @@ import { HttpStart } from 'kibana/public'; import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, + TRUSTED_APPS_GET_API, TRUSTED_APPS_LIST_API, + TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../../common/endpoint/constants'; @@ -21,19 +23,39 @@ import { PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, GetTrustedAppsSummaryResponse, + PutTrustedAppUpdateRequest, + PutTrustedAppUpdateResponse, + PutTrustedAppsRequestParams, + GetOneTrustedAppRequestParams, + GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; import { resolvePathVariables } from './utils'; +import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { + getTrustedApp(params: GetOneTrustedAppRequestParams): Promise; getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; + updateTrustedApp( + params: PutTrustedAppsRequestParams, + request: PutTrustedAppUpdateRequest + ): Promise; + getPolicyList( + options?: Parameters[1] + ): ReturnType; } export class TrustedAppsHttpService implements TrustedAppsService { constructor(private http: HttpStart) {} + async getTrustedApp(params: GetOneTrustedAppRequestParams) { + return this.http.get( + resolvePathVariables(TRUSTED_APPS_GET_API, params) + ); + } + async getTrustedAppsList(request: GetTrustedAppsListRequest) { return this.http.get(TRUSTED_APPS_LIST_API, { query: request, @@ -50,7 +72,21 @@ export class TrustedAppsHttpService implements TrustedAppsService { }); } + async updateTrustedApp( + params: PutTrustedAppsRequestParams, + updatedTrustedApp: PutTrustedAppUpdateRequest + ) { + return this.http.put( + resolvePathVariables(TRUSTED_APPS_UPDATE_API, params), + { body: JSON.stringify(updatedTrustedApp) } + ); + } + async getTrustedAppsSummary() { return this.http.get(TRUSTED_APPS_SUMMARY_API); } + + getPolicyList(options?: Parameters[1]) { + return sendGetEndpointSpecificPackagePolicies(this.http, options); + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index ea934881f6220..1c1fca4b55abc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -7,6 +7,7 @@ import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; +import { GetPolicyListResponse } from '../../policy/types'; export interface Pagination { pageIndex: number; @@ -29,7 +30,9 @@ export interface TrustedAppsListPageLocation { page_index: number; page_size: number; view_type: ViewType; - show?: 'create'; + show?: 'create' | 'edit'; + /** Used for editing. The ID of the selected trusted app */ + id?: string; } export interface TrustedAppsListPageState { @@ -51,9 +54,13 @@ export interface TrustedAppsListPageState { entry: NewTrustedApp; isValid: boolean; }; + /** The trusted app to be edited (when in edit mode) */ + editItem?: AsyncResourceState; confirmed: boolean; submissionResourceState: AsyncResourceState; }; + /** A list of all available polices for use in associating TA to policies */ + policies: AsyncResourceState; location: TrustedAppsListPageLocation; active: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 66f4eff81dbdd..3f9e9d53f69e4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -8,7 +8,11 @@ import { ConditionEntry, ConditionEntryField, + EffectScope, + GlobalEffectScope, MacosLinuxConditionEntry, + MaybeImmutable, + PolicyEffectScope, WindowsConditionEntry, } from '../../../../../common/endpoint/types'; @@ -23,3 +27,15 @@ export const isMacosLinuxTrustedAppCondition = ( ): condition is MacosLinuxConditionEntry => { return condition.field !== ConditionEntryField.SIGNER; }; + +export const isGlobalEffectScope = ( + effectedScope: MaybeImmutable +): effectedScope is GlobalEffectScope => { + return effectedScope.type === 'global'; +}; + +export const isPolicyEffectScope = ( + effectedScope: MaybeImmutable +): effectedScope is PolicyEffectScope => { + return effectedScope.type === 'policy'; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index aaa05f550b208..34f48142c7032 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -9,6 +9,7 @@ import { Action } from 'redux'; import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, TrustedAppsListData } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export type TrustedAppsListDataOutdated = Action<'trustedAppsListDataOutdated'>; @@ -51,6 +52,10 @@ export type TrustedAppCreationDialogFormStateUpdated = Action<'trustedAppCreatio }; }; +export type TrustedAppCreationEditItemStateChanged = Action<'trustedAppCreationEditItemStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppCreationDialogConfirmed = Action<'trustedAppCreationDialogConfirmed'>; export type TrustedAppCreationDialogClosed = Action<'trustedAppCreationDialogClosed'>; @@ -59,6 +64,10 @@ export type TrustedAppsExistResponse = Action<'trustedAppsExistStateChanged'> & payload: AsyncResourceState; }; +export type TrustedAppsPoliciesStateChanged = Action<'trustedAppsPoliciesStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppsPageAction = | TrustedAppsListDataOutdated | TrustedAppsListResourceStateChanged @@ -67,8 +76,10 @@ export type TrustedAppsPageAction = | TrustedAppDeletionDialogConfirmed | TrustedAppDeletionDialogClosed | TrustedAppCreationSubmissionResourceStateChanged + | TrustedAppCreationEditItemStateChanged | TrustedAppCreationDialogStarted | TrustedAppCreationDialogFormStateUpdated | TrustedAppCreationDialogConfirmed | TrustedAppsExistResponse + | TrustedAppsPoliciesStateChanged | TrustedAppCreationDialogClosed; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 3acb55904d298..ece2c9e29750f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -28,6 +28,7 @@ export const defaultNewTrustedApp = (): NewTrustedApp => ({ os: OperatingSystem.WINDOWS, entries: [defaultConditionEntry()], description: '', + effectScope: { type: 'global' }, }); export const initialDeletionDialogState = (): TrustedAppsListPageState['deletionDialog'] => ({ @@ -48,10 +49,12 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ }, deletionDialog: initialDeletionDialogState(), creationDialog: initialCreationDialogState(), + policies: { type: 'UninitialisedResourceState' }, location: { page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, show: undefined, + id: undefined, view_type: 'grid', }, active: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index 064b108848d2f..ed45d077dd0ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -21,10 +21,11 @@ import { } from '../test_utils'; import { TrustedAppsService } from '../service'; -import { Pagination, TrustedAppsListPageState } from '../state'; +import { Pagination, TrustedAppsListPageLocation, TrustedAppsListPageState } from '../state'; import { initialTrustedAppsPageState } from './builders'; import { trustedAppsPageReducer } from './reducer'; import { createTrustedAppsPageMiddleware } from './middleware'; +import { Immutable } from '../../../../../common/endpoint/types'; const initialNow = 111111; const dateNowMock = jest.fn(); @@ -32,7 +33,7 @@ dateNowMock.mockReturnValue(initialNow); Date.now = dateNowMock; -const initialState = initialTrustedAppsPageState(); +const initialState: Immutable = initialTrustedAppsPageState(); const createGetTrustedListAppsResponse = (pagination: Partial) => { const fullPagination = { ...createDefaultPagination(), ...pagination }; @@ -49,6 +50,9 @@ const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), deleteTrustedApp: jest.fn(), createTrustedApp: jest.fn(), + getPolicyList: jest.fn(), + updateTrustedApp: jest.fn(), + getTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { @@ -87,6 +91,15 @@ describe('middleware', () => { }; }; + const createLocationState = ( + params?: Partial + ): TrustedAppsListPageLocation => { + return { + ...initialState.location, + ...(params ?? {}), + }; + }; + beforeEach(() => { dateNowMock.mockReturnValue(initialNow); }); @@ -102,7 +115,10 @@ describe('middleware', () => { describe('refreshing list resource state', () => { it('refreshes the list when location changes and data gets outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -136,7 +152,10 @@ describe('middleware', () => { it('does not refresh the list when location changes and data does not get outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -161,7 +180,7 @@ describe('middleware', () => { it('refreshes the list when data gets outdated with and outdate action', async () => { const newNow = 222222; const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -224,7 +243,10 @@ describe('middleware', () => { freshDataTimestamp: initialNow, }, active: true, - location: { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }, + location: createLocationState({ + page_index: 2, + page_size: 50, + }), }); const infiniteLoopTest = async () => { @@ -240,7 +262,7 @@ describe('middleware', () => { const entry = createSampleTrustedApp(3); const notFoundError = createServerApiError('Not Found'); const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination); const listView = createLoadedListViewWithPagination(initialNow, pagination); const listViewNew = createLoadedListViewWithPagination(newNow, pagination); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 3e83b213f0f7e..7f940f14f9c6c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { Immutable, PostTrustedAppCreateRequest, @@ -54,7 +55,15 @@ import { getListTotalItemsCount, trustedAppsListPageActive, entriesExistState, + policiesState, + isEdit, + isFetchingEditTrustedAppItem, + editItemId, + editingTrustedApp, + getListItems, + editItemState, } from './selectors'; +import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; const createTrustedAppsListResourceStateChangedAction = ( newState: Immutable> @@ -139,9 +148,11 @@ const submitCreationIfNeeded = async ( store: ImmutableMiddlewareAPI, trustedAppsService: TrustedAppsService ) => { - const submissionResourceState = getCreationSubmissionResourceState(store.getState()); - const isValid = isCreationDialogFormValid(store.getState()); - const entry = getCreationDialogFormEntry(store.getState()); + const currentState = store.getState(); + const submissionResourceState = getCreationSubmissionResourceState(currentState); + const isValid = isCreationDialogFormValid(currentState); + const entry = getCreationDialogFormEntry(currentState); + const editMode = isEdit(currentState); if (isStaleResourceState(submissionResourceState) && entry !== undefined && isValid) { store.dispatch( @@ -152,12 +163,27 @@ const submitCreationIfNeeded = async ( ); try { + let responseTrustedApp: TrustedApp; + + if (editMode) { + responseTrustedApp = ( + await trustedAppsService.updateTrustedApp( + { id: editItemId(currentState)! }, + // TODO: try to remove the cast + entry as PostTrustedAppCreateRequest + ) + ).data; + } else { + // TODO: try to remove the cast + responseTrustedApp = ( + await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest) + ).data; + } + store.dispatch( createTrustedAppCreationSubmissionResourceStateChanged({ type: 'LoadedResourceState', - // TODO: try to remove the cast - data: (await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest)) - .data, + data: responseTrustedApp, }) ); store.dispatch({ @@ -268,6 +294,139 @@ const checkTrustedAppsExistIfNeeded = async ( } }; +export const retrieveListOfPoliciesIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const currentPoliciesState = policiesState(currentState); + const isLoading = isLoadingResourceState(currentPoliciesState); + const isPageActive = trustedAppsListPageActive(currentState); + const isCreateFlow = isCreationDialogLocation(currentState); + + if (isPageActive && isCreateFlow && !isLoading) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: currentPoliciesState, + } as TrustedAppsListPageState['policies'], + }); + + try { + const policyList = await trustedAppsService.getPolicyList({ + query: { + page: 1, + perPage: 1000, + }, + }); + + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadedResourceState', + data: policyList, + }, + }); + } catch (error) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'FailedResourceState', + error: error.body || error, + lastLoadedState: getLastLoadedResourceState(policiesState(getState())), + }, + }); + } + } +}; + +const fetchEditTrustedAppIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const isPageActive = trustedAppsListPageActive(currentState); + const isEditFlow = isEdit(currentState); + const isAlreadyFetching = isFetchingEditTrustedAppItem(currentState); + const editTrustedAppId = editItemId(currentState); + + if (isPageActive && isEditFlow && !isAlreadyFetching) { + if (!editTrustedAppId) { + const errorMessage = i18n.translate( + 'xpack.securitySolution.trustedapps.middleware.editIdMissing', + { + defaultMessage: 'No id provided', + } + ); + + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: Object.assign(new Error(errorMessage), { statusCode: 404, error: errorMessage }), + }, + }); + return; + } + + let trustedAppForEdit = editingTrustedApp(currentState); + + // If Trusted App is already loaded, then do nothing + if (trustedAppForEdit && trustedAppForEdit.id === editTrustedAppId) { + return; + } + + // See if we can get the Trusted App record from the current list of Trusted Apps being displayed + trustedAppForEdit = getListItems(currentState).find((ta) => ta.id === editTrustedAppId); + + try { + // Retrieve Trusted App record via API if it was not in the list data. + // This would be the case when linking from another place or using an UUID for a Trusted App + // that is not currently displayed on the list view. + if (!trustedAppForEdit) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadingResourceState', + // No easy way to get around this that I can see. `previousState` does not + // seem to allow everything that `editItem` state can hold, so not even sure if using + // type guards would work here + // @ts-ignore + previousState: editItemState(currentState)!, + }, + }); + + trustedAppForEdit = (await trustedAppsService.getTrustedApp({ id: editTrustedAppId })).data; + } + + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadedResourceState', + data: trustedAppForEdit, + }, + }); + + dispatch({ + type: 'trustedAppCreationDialogFormStateUpdated', + payload: { + entry: toUpdateTrustedApp(trustedAppForEdit), + isValid: true, + }, + }); + } catch (e) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: e, + }, + }); + } + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -282,6 +441,8 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl') { updateCreationDialogIfNeeded(store); + retrieveListOfPoliciesIfNeeded(store, trustedAppsService); + fetchEditTrustedAppIfNeeded(store, trustedAppsService); } if (action.type === 'trustedAppCreationDialogConfirmed') { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 5f37d0d674558..6965172ef773d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -37,7 +37,13 @@ describe('reducer', () => { expect(result).toStrictEqual({ ...initialState, - location: { page_index: 5, page_size: 50, show: 'create', view_type: 'list' }, + location: { + page_index: 5, + page_size: 50, + show: 'create', + view_type: 'list', + id: undefined, + }, active: true, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index aff5cacf081c6..ea7bbb44c9bf2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -29,6 +29,8 @@ import { TrustedAppCreationDialogConfirmed, TrustedAppCreationDialogClosed, TrustedAppsExistResponse, + TrustedAppsPoliciesStateChanged, + TrustedAppCreationEditItemStateChanged, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -37,7 +39,7 @@ import { initialDeletionDialogState, initialTrustedAppsPageState, } from './builders'; -import { entriesExistState } from './selectors'; +import { entriesExistState, trustedAppsListPageActive } from './selectors'; type StateReducer = ImmutableReducer; type CaseReducer = ( @@ -110,7 +112,7 @@ const trustedAppCreationDialogStarted: CaseReducer = ( + state, + action +) => { + return { + ...state, + creationDialog: { ...state.creationDialog, editItem: action.payload }, + }; +}; + const trustedAppCreationDialogConfirmed: CaseReducer = ( state ) => { @@ -155,6 +167,16 @@ const updateEntriesExists: CaseReducer = (state, { pay return state; }; +const updatePolicies: CaseReducer = (state, { payload }) => { + if (trustedAppsListPageActive(state)) { + return { + ...state, + policies: payload, + }; + } + return state; +}; + export const trustedAppsPageReducer: StateReducer = ( state = initialTrustedAppsPageState(), action @@ -187,6 +209,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppCreationDialogFormStateUpdated': return trustedAppCreationDialogFormStateUpdated(state, action); + case 'trustedAppCreationEditItemStateChanged': + return handleUpdateToEditItemState(state, action); + case 'trustedAppCreationDialogConfirmed': return trustedAppCreationDialogConfirmed(state, action); @@ -198,6 +223,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppsExistStateChanged': return updateEntriesExists(state, action); + + case 'trustedAppsPoliciesStateChanged': + return updatePolicies(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index baa68eb314140..7c131c3eaa7a9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -24,6 +24,7 @@ import { TrustedAppsListPageLocation, TrustedAppsListPageState, } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export const needsRefreshOfListData = (state: Immutable): boolean => { const freshDataTimestamp = state.listView.freshDataTimestamp; @@ -130,7 +131,7 @@ export const getDeletionDialogEntry = ( }; export const isCreationDialogLocation = (state: Immutable): boolean => { - return state.location.show === 'create'; + return !!state.location.show; }; export const getCreationSubmissionResourceState = ( @@ -185,3 +186,56 @@ export const entriesExist: (state: Immutable) => boole export const trustedAppsListPageActive: (state: Immutable) => boolean = ( state ) => state.active; + +export const policiesState = ( + state: Immutable +): Immutable => state.policies; + +export const loadingPolicies: ( + state: Immutable +) => boolean = createSelector(policiesState, (policies) => isLoadingResourceState(policies)); + +export const listOfPolicies: ( + state: Immutable +) => Immutable = createSelector(policiesState, (policies) => { + return isLoadedResourceState(policies) ? policies.data.items : []; +}); + +export const isEdit: (state: Immutable) => boolean = createSelector( + getCurrentLocation, + ({ show }) => { + return show === 'edit'; + } +); + +export const editItemId: ( + state: Immutable +) => string | undefined = createSelector(getCurrentLocation, ({ id }) => { + return id; +}); + +export const editItemState: ( + state: Immutable +) => Immutable['creationDialog']['editItem'] = (state) => { + return state.creationDialog.editItem; +}; + +export const isFetchingEditTrustedAppItem: ( + state: Immutable +) => boolean = createSelector(editItemState, (editTrustedAppState) => { + return editTrustedAppState ? isLoadingResourceState(editTrustedAppState) : false; +}); + +export const editTrustedAppFetchError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(editItemState, (itemForEditState) => { + return itemForEditState && getCurrentResourceError(itemForEditState); +}); + +export const editingTrustedApp: ( + state: Immutable +) => undefined | Immutable = createSelector(editItemState, (editTrustedAppState) => { + if (editTrustedAppState && isLoadedResourceState(editTrustedAppState)) { + return editTrustedAppState.data; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index faf111b1a55d8..faffc6b04a0cd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -44,12 +44,16 @@ const generate = (count: number, generator: (i: number) => T) => export const createSampleTrustedApp = (i: number, longTexts?: boolean): TrustedApp => { return { id: String(i), + version: 'abc123', name: generate(longTexts ? 10 : 1, () => `trusted app ${i}`).join(' '), description: generate(longTexts ? 10 : 1, () => `Trusted App ${i}`).join(' '), created_at: '1 minute ago', created_by: 'someone', + updated_at: '1 minute ago', + updated_by: 'someone', os: OPERATING_SYSTEMS[i % 3], entries: [], + effectScope: { type: 'global' }, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap index 720bbb0b76164..0a1b1d2552302 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap @@ -23,7 +23,7 @@ exports[`TrustedAppDeletionDialog renders correctly when deletion failed 1`] = ` > +
+
@@ -372,7 +393,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -624,7 +666,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -876,7 +939,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -1128,7 +1212,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -1380,7 +1485,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -1632,7 +1758,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -1884,7 +2031,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -2136,7 +2304,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -2388,7 +2577,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -2689,7 +2899,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` > +
+
@@ -3181,7 +3412,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -3433,7 +3685,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -3685,7 +3958,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -3937,7 +4231,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
-
+
+
+ +
+
+
@@ -4189,7 +4504,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -4441,7 +4777,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -4693,7 +5050,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -4945,7 +5323,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -5197,7 +5596,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -5498,7 +5918,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time > +
+
@@ -5948,7 +6389,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -6200,7 +6662,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -6452,7 +6935,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -6704,7 +7208,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -6956,7 +7481,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -7208,7 +7754,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -7460,7 +8027,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -7712,7 +8300,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -7964,7 +8573,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not class="euiFlexItem euiFlexItem--flexGrowZero" >
+
+
+ +
+
@@ -8265,7 +8895,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not > +
+
@@ -1023,6 +1046,7 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` >