From 2e34eb239fe753eb09a36e199a8af4792cfb2434 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 14 Sep 2020 09:30:47 -0400 Subject: [PATCH 01/91] Hide management sections based on cluster/index privileges (#67791) Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 1 + .../security/feature-registration.asciidoc | 48 +- .../manage-policy.asciidoc | 6 + docs/management/managing-ccr.asciidoc | 7 + docs/management/managing-licenses.asciidoc | 7 + .../managing-remote-clusters.asciidoc | 7 + .../create_and_manage_rollups.asciidoc | 7 + .../upgrade-assistant/index.asciidoc | 8 + docs/migration/migrate_8_0.asciidoc | 14 +- examples/alerting_example/server/plugin.ts | 2 +- src/core/MIGRATION.md | 2 +- src/plugins/management/public/plugin.ts | 20 + test/common/services/security/test_user.ts | 4 +- test/functional/services/index.ts | 2 + test/functional/services/management/index.ts | 20 + .../services/management/management_menu.ts | 51 + .../plugins/xpack_main/server/xpack_main.d.ts | 2 +- .../actions_authorization.test.ts | 14 +- .../authorization/actions_authorization.ts | 8 +- x-pack/plugins/actions/server/plugin.ts | 2 +- .../alerting_builtins/server/plugin.test.ts | 2 +- .../alerting_builtins/server/plugin.ts | 2 +- x-pack/plugins/alerts/README.md | 4 +- .../alerts_authorization.test.ts | 405 +-- .../authorization/alerts_authorization.ts | 41 +- x-pack/plugins/alerts/server/plugin.test.ts | 6 +- .../apm/e2e/cypress/integration/apm.feature | 2 +- x-pack/plugins/apm/server/plugin.ts | 2 +- x-pack/plugins/beats_management/kibana.json | 3 +- .../plugins/beats_management/server/plugin.ts | 18 +- x-pack/plugins/canvas/server/plugin.ts | 2 +- .../cross_cluster_replication/kibana.json | 3 +- .../server/plugin.ts | 15 +- .../cross_cluster_replication/server/types.ts | 2 + .../server/lib/check_access.ts | 2 +- .../enterprise_search/server/plugin.ts | 2 +- .../features/common/elasticsearch_feature.ts | 85 + .../feature_elasticsearch_privileges.ts | 72 + x-pack/plugins/features/common/index.ts | 4 +- .../common/{feature.ts => kibana_feature.ts} | 14 +- .../features/public/features_api_client.ts | 6 +- x-pack/plugins/features/public/index.ts | 4 +- .../feature_registry.test.ts.snap | 26 +- .../features/server/feature_registry.test.ts | 2175 +++++++++------- .../features/server/feature_registry.ts | 54 +- .../plugins/features/server/feature_schema.ts | 71 +- x-pack/plugins/features/server/index.ts | 9 +- x-pack/plugins/features/server/mocks.ts | 9 +- .../features/server/oss_features.test.ts | 4 +- .../plugins/features/server/oss_features.ts | 6 +- x-pack/plugins/features/server/plugin.test.ts | 48 +- x-pack/plugins/features/server/plugin.ts | 43 +- .../features/server/routes/index.test.ts | 18 +- .../plugins/features/server/routes/index.ts | 2 +- .../ui_capabilities_for_features.test.ts | 530 ++-- .../server/ui_capabilities_for_features.ts | 79 +- x-pack/plugins/graph/server/plugin.ts | 2 +- .../index_lifecycle_management/kibana.json | 3 +- .../server/plugin.ts | 18 +- .../server/types.ts | 2 + x-pack/plugins/index_management/kibana.json | 3 +- .../plugins/index_management/server/plugin.ts | 15 +- .../plugins/index_management/server/types.ts | 2 + x-pack/plugins/infra/server/plugin.ts | 4 +- .../plugins/ingest_manager/server/plugin.ts | 2 +- x-pack/plugins/ingest_pipelines/kibana.json | 2 +- .../plugins/ingest_pipelines/server/plugin.ts | 15 +- .../plugins/ingest_pipelines/server/types.ts | 2 + x-pack/plugins/license_management/kibana.json | 2 +- .../license_management/server/plugin.ts | 15 +- .../license_management/server/types.ts | 2 + x-pack/plugins/logstash/kibana.json | 3 +- x-pack/plugins/logstash/server/plugin.ts | 18 + x-pack/plugins/maps/server/plugin.ts | 2 +- .../plugins/ml/common/types/capabilities.ts | 1 + .../ml/public/application/management/index.ts | 2 +- x-pack/plugins/ml/public/plugin.ts | 19 +- x-pack/plugins/ml/server/plugin.ts | 2 +- x-pack/plugins/monitoring/server/plugin.ts | 2 +- x-pack/plugins/remote_clusters/kibana.json | 3 +- .../plugins/remote_clusters/server/plugin.ts | 15 +- .../plugins/remote_clusters/server/types.ts | 2 + x-pack/plugins/reporting/kibana.json | 3 +- x-pack/plugins/reporting/server/core.ts | 22 + .../plugins/reporting/server/plugin.test.ts | 2 + x-pack/plugins/reporting/server/plugin.ts | 5 +- .../create_mock_reportingplugin.ts | 2 + x-pack/plugins/reporting/server/types.ts | 2 + x-pack/plugins/rollup/kibana.json | 3 +- x-pack/plugins/rollup/server/plugin.ts | 16 +- x-pack/plugins/rollup/server/types.ts | 2 + .../management/management_service.test.ts | 44 +- .../public/management/management_service.ts | 13 +- .../roles/__fixtures__/kibana_features.ts | 11 +- .../roles/__fixtures__/kibana_privileges.ts | 8 +- .../roles/edit_role/edit_role_page.test.tsx | 8 +- .../roles/edit_role/edit_role_page.tsx | 6 +- .../feature_table/feature_table.test.tsx | 4 +- .../privilege_space_table.test.tsx | 10 +- .../roles/model/kibana_privileges.ts | 4 +- .../management/roles/model/secured_feature.ts | 9 +- .../plugins/security/public/plugin.test.tsx | 3 +- x-pack/plugins/security/public/plugin.tsx | 2 +- .../authorization/api_authorization.test.ts | 8 +- .../server/authorization/api_authorization.ts | 2 +- .../authorization/app_authorization.test.ts | 6 +- .../server/authorization/app_authorization.ts | 4 +- .../authorization_service.test.ts | 8 +- .../authorization/authorization_service.ts | 14 +- .../authorization/check_privileges.test.ts | 2284 +++++++++++++++-- .../server/authorization/check_privileges.ts | 97 +- .../check_privileges_dynamically.test.ts | 10 +- .../check_privileges_dynamically.ts | 11 +- .../check_saved_objects_privileges.test.ts | 10 +- .../check_saved_objects_privileges.ts | 8 +- .../disable_ui_capabilities.test.ts | 245 +- .../authorization/disable_ui_capabilities.ts | 178 +- .../server/authorization/index.mock.ts | 1 + .../alerting.test.ts | 10 +- .../feature_privilege_builder/alerting.ts | 7 +- .../feature_privilege_builder/api.ts | 4 +- .../feature_privilege_builder/app.ts | 4 +- .../feature_privilege_builder/catalogue.ts | 4 +- .../feature_privilege_builder.ts | 6 +- .../feature_privilege_builder/index.ts | 4 +- .../feature_privilege_builder/management.ts | 4 +- .../feature_privilege_builder/navlink.ts | 4 +- .../feature_privilege_builder/saved_object.ts | 4 +- .../feature_privilege_builder/ui.ts | 7 +- .../feature_privilege_iterator.test.ts | 22 +- .../feature_privilege_iterator.ts | 6 +- .../sub_feature_privilege_iterator.ts | 5 +- .../privileges/privileges.test.ts | 212 +- .../authorization/privileges/privileges.ts | 10 +- .../security/server/authorization/types.ts | 56 + .../validate_feature_privileges.test.ts | 20 +- .../validate_feature_privileges.ts | 6 +- .../validate_reserved_privileges.test.ts | 16 +- .../validate_reserved_privileges.ts | 4 +- .../plugins/security/server/features/index.ts | 7 + .../server/features/security_features.ts | 74 + x-pack/plugins/security/server/plugin.test.ts | 2 + x-pack/plugins/security/server/plugin.ts | 14 +- .../routes/authorization/roles/put.test.ts | 4 +- .../server/routes/authorization/roles/put.ts | 6 +- .../plugins/security/server/routes/index.ts | 4 +- ...ecure_saved_objects_client_wrapper.test.ts | 40 +- .../secure_saved_objects_client_wrapper.ts | 10 +- .../security_solution/server/plugin.ts | 2 +- x-pack/plugins/snapshot_restore/kibana.json | 3 +- .../plugins/snapshot_restore/server/plugin.ts | 17 +- .../plugins/snapshot_restore/server/types.ts | 2 + .../enabled_features.test.tsx | 4 +- .../enabled_features/enabled_features.tsx | 4 +- .../enabled_features/feature_table.tsx | 8 +- .../edit_space/manage_space_page.test.tsx | 4 +- .../edit_space/manage_space_page.tsx | 6 +- .../management/lib/feature_utils.test.ts | 4 +- .../public/management/lib/feature_utils.ts | 4 +- .../spaces_grid/spaces_grid_page.tsx | 4 +- .../spaces_grid/spaces_grid_pages.test.tsx | 4 +- .../capabilities_provider.test.ts | 3 + .../capabilities/capabilities_provider.ts | 3 + .../capabilities_switcher.test.ts | 6 +- .../capabilities/capabilities_switcher.ts | 14 +- .../on_post_auth_interceptor.test.ts | 6 +- .../on_post_auth_interceptor.ts | 2 +- .../lib/spaces_client/spaces_client.test.ts | 92 +- .../server/lib/spaces_client/spaces_client.ts | 18 +- x-pack/plugins/spaces/server/plugin.test.ts | 10 +- .../spaces_usage_collector.test.ts | 8 +- .../spaces_usage_collector.ts | 2 +- x-pack/plugins/transform/kibana.json | 3 +- x-pack/plugins/transform/server/plugin.ts | 16 +- x-pack/plugins/transform/server/types.ts | 2 + x-pack/plugins/upgrade_assistant/kibana.json | 2 +- .../upgrade_assistant/server/plugin.ts | 17 +- x-pack/plugins/uptime/server/kibana.index.ts | 2 +- x-pack/plugins/watcher/kibana.json | 3 +- x-pack/plugins/watcher/server/plugin.ts | 30 +- x-pack/plugins/watcher/server/types.ts | 2 + .../actions_simulators/server/plugin.ts | 2 +- .../fixtures/plugins/alerts/server/plugin.ts | 2 +- .../alerts_restricted/server/plugin.ts | 2 +- .../apis/features/features/features.ts | 4 +- .../advanced_settings_security.ts | 11 +- .../feature_controls/api_keys_security.ts | 69 + .../apps/api_keys/feature_controls/index.ts | 15 + .../functional/apps/api_keys/home_page.ts | 6 +- x-pack/test/functional/apps/api_keys/index.ts | 1 + .../feature_controls/canvas_security.ts | 4 +- .../feature_controls/ccr_security.ts | 77 + .../feature_controls/index.ts | 15 + .../apps/cross_cluster_replication/index.ts | 1 + .../feature_controls/dashboard_security.ts | 8 +- .../feature_controls/dev_tools_security.ts | 4 +- .../feature_controls/discover_security.ts | 6 +- .../graph/feature_controls/graph_security.ts | 4 +- .../feature_controls/ilm_security.ts | 69 + .../feature_controls/index.ts | 15 + .../apps/index_lifecycle_management/index.ts | 1 + .../feature_controls/index.ts | 15 + .../index_management_security.ts | 69 + .../functional/apps/index_management/index.ts | 1 + .../index_patterns_security.ts | 20 +- .../infra/feature_controls/logs_security.ts | 4 +- .../feature_controls/index.ts | 15 + .../ingest_pipelines_security.ts | 69 + .../functional/apps/ingest_pipelines/index.ts | 1 + .../feature_controls/index.ts | 15 + .../license_management_security.ts | 69 + .../apps/license_management/index.ts | 1 + .../apps/logstash/feature_controls/index.ts | 15 + .../feature_controls/logstash_security.ts | 69 + x-pack/test/functional/apps/logstash/index.js | 1 + .../apps/management/feature_controls/index.ts | 15 + .../feature_controls/management_security.ts | 74 + .../apps/management/{index.js => index.ts} | 5 +- .../maps/feature_controls/maps_security.ts | 4 +- .../apps/ml/permissions/no_ml_access.ts | 11 +- .../apps/ml/permissions/read_ml_access.ts | 11 +- .../remote_clusters/feature_controls/index.ts | 15 + .../remote_clusters_security.ts | 76 + .../functional/apps/remote_clusters/index.ts | 1 + .../saved_objects_management_security.ts | 20 +- .../apps/security/secure_roles_perm.js | 8 +- .../feature_controls/timelion_security.ts | 4 +- .../apps/transform/feature_controls/index.ts | 15 + .../feature_controls/transform_security.ts | 70 + .../test/functional/apps/transform/index.ts | 1 + .../feature_controls/index.ts | 15 + .../upgrade_assistant_security.ts | 72 + .../apps/upgrade_assistant/index.ts | 1 + .../feature_controls/visualize_security.ts | 6 +- x-pack/test/functional/config.js | 69 + .../test/functional/services/ml/navigation.ts | 26 +- .../fixtures/plugins/alerts/server/plugin.ts | 2 +- .../plugins/foo_plugin/server/index.ts | 2 +- .../security_and_spaces/tests/catalogue.ts | 49 +- .../security_only/tests/catalogue.ts | 8 +- .../spaces_only/tests/catalogue.ts | 10 +- 241 files changed, 6998 insertions(+), 2380 deletions(-) create mode 100644 test/functional/services/management/index.ts create mode 100644 test/functional/services/management/management_menu.ts create mode 100644 x-pack/plugins/features/common/elasticsearch_feature.ts create mode 100644 x-pack/plugins/features/common/feature_elasticsearch_privileges.ts rename x-pack/plugins/features/common/{feature.ts => kibana_feature.ts} (92%) create mode 100644 x-pack/plugins/security/server/features/index.ts create mode 100644 x-pack/plugins/security/server/features/security_features.ts create mode 100644 x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts create mode 100644 x-pack/test/functional/apps/api_keys/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts create mode 100644 x-pack/test/functional/apps/cross_cluster_replication/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts create mode 100644 x-pack/test/functional/apps/index_lifecycle_management/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/index_management/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts create mode 100644 x-pack/test/functional/apps/ingest_pipelines/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts create mode 100644 x-pack/test/functional/apps/license_management/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts create mode 100644 x-pack/test/functional/apps/logstash/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts create mode 100644 x-pack/test/functional/apps/management/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/management/feature_controls/management_security.ts rename x-pack/test/functional/apps/management/{index.js => index.ts} (67%) create mode 100644 x-pack/test/functional/apps/remote_clusters/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts create mode 100644 x-pack/test/functional/apps/transform/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/transform/feature_controls/transform_security.ts create mode 100644 x-pack/test/functional/apps/upgrade_assistant/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bcb4774475849..5efbaba32e00a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -169,6 +169,7 @@ /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security /x-pack/test/api_integration/apis/security/ @elastic/kibana-security +/x-pack/test/ui_capabilities/ @elastic/kibana-security /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security /x-pack/test/kerberos_api_integration/ @elastic/kibana-security diff --git a/docs/developer/architecture/security/feature-registration.asciidoc b/docs/developer/architecture/security/feature-registration.asciidoc index 3724624dbb917..3ff83e9db8c43 100644 --- a/docs/developer/architecture/security/feature-registration.asciidoc +++ b/docs/developer/architecture/security/feature-registration.asciidoc @@ -9,13 +9,12 @@ Registering features also gives your plugin access to “UI Capabilities”. The === Registering a feature -Feature registration is controlled via the built-in `xpack_main` plugin. To register a feature, call `xpack_main`'s `registerFeature` function from your plugin's `init` function, and provide the appropriate details: +Feature registration is controlled via the built-in `features` plugin. To register a feature, call `features`'s `registerKibanaFeature` function from your plugin's `setup` lifecycle function, and provide the appropriate details: ["source","javascript"] ----------- -init(server) { - const xpackMainPlugin = server.plugins.xpack_main; - xpackMainPlugin.registerFeature({ +setup(core, { features }) { + features.registerKibanaFeature({ // feature details here. }); } @@ -45,12 +44,12 @@ Registering a feature consists of the following fields. For more information, co |An array of applications this feature enables. Typically, all of your plugin's apps (from `uiExports`) will be included here. |`privileges` (required) -|{kib-repo}blob/{branch}/x-pack/plugins/features/common/feature.ts[`FeatureConfig`]. +|{kib-repo}blob/{branch}/x-pack/plugins/features/common/feature.ts[`KibanaFeatureConfig`]. |See <> and <> |The set of privileges this feature requires to function. |`subFeatures` (optional) -|{kib-repo}blob/{branch}/x-pack/plugins/features/common/feature.ts[`FeatureConfig`]. +|{kib-repo}blob/{branch}/x-pack/plugins/features/common/feature.ts[`KibanaFeatureConfig`]. |See <> |The set of subfeatures that enables finer access control than the `all` and `read` feature privileges. These options are only available in the Gold subscription level and higher. @@ -73,15 +72,17 @@ For a full explanation of fields and options, consult the {kib-repo}blob/{branch === Using UI Capabilities UI Capabilities are available to your public (client) plugin code. These capabilities are read-only, and are used to inform the UI. This object is namespaced by feature id. For example, if your feature id is “foo”, then your UI Capabilities are stored at `uiCapabilities.foo`. -To access capabilities, import them from `ui/capabilities`: +Capabilities can be accessed from your plugin's `start` lifecycle from the `core.application` service: ["source","javascript"] ----------- -import { uiCapabilities } from 'ui/capabilities'; +public start(core) { + const { capabilities } = core.application; -const canUserSave = uiCapabilities.foo.save; -if (canUserSave) { - // show save button + const canUserSave = capabilities.foo.save; + if (canUserSave) { + // show save button + } } ----------- @@ -89,9 +90,8 @@ if (canUserSave) { === Example 1: Canvas Application ["source","javascript"] ----------- -init(server) { - const xpackMainPlugin = server.plugins.xpack_main; - xpackMainPlugin.registerFeature({ +public setup(core, { features }) { + features.registerKibanaFeature({ id: 'canvas', name: 'Canvas', icon: 'canvasApp', @@ -130,11 +130,13 @@ The `all` privilege defines a single “save” UI Capability. To access this in ["source","javascript"] ----------- -import { uiCapabilities } from 'ui/capabilities'; +public start(core) { + const { capabilities } = core.application; -const canUserSave = uiCapabilities.canvas.save; -if (canUserSave) { - // show save button + const canUserSave = capabilities.canvas.save; + if (canUserSave) { + // show save button + } } ----------- @@ -145,9 +147,8 @@ Because the `read` privilege does not define the `save` capability, users with r ["source","javascript"] ----------- -init(server) { - const xpackMainPlugin = server.plugins.xpack_main; - xpackMainPlugin.registerFeature({ +public setup(core, { features }) { + features.registerKibanaFeature({ id: 'dev_tools', name: i18n.translate('xpack.features.devToolsFeatureName', { defaultMessage: 'Dev Tools', @@ -206,9 +207,8 @@ a single "Create Short URLs" subfeature privilege is defined, which allows users ["source","javascript"] ----------- -init(server) { - const xpackMainPlugin = server.plugins.xpack_main; - xpackMainPlugin.registerFeature({ +public setup(core, { features }) { + features.registerKibanaFeature({ { id: 'discover', name: i18n.translate('xpack.features.discoverFeatureName', { diff --git a/docs/management/index-lifecycle-policies/manage-policy.asciidoc b/docs/management/index-lifecycle-policies/manage-policy.asciidoc index a57af8a33494b..8e2dc96de4b99 100644 --- a/docs/management/index-lifecycle-policies/manage-policy.asciidoc +++ b/docs/management/index-lifecycle-policies/manage-policy.asciidoc @@ -25,4 +25,10 @@ created index. For more information, see {ref}/indices-templates.html[Index temp * *Delete a policy.* You can’t delete a policy that is currently in use or recover a deleted index. +[float] +=== Required permissions + +The `manage_ilm` cluster privilege is required to access *Index lifecycle policies*. + +You can add these privileges in *Stack Management > Security > Roles*. diff --git a/docs/management/managing-ccr.asciidoc b/docs/management/managing-ccr.asciidoc index 67193b3b5a037..9c06e479e28b2 100644 --- a/docs/management/managing-ccr.asciidoc +++ b/docs/management/managing-ccr.asciidoc @@ -20,6 +20,13 @@ image::images/cross-cluster-replication-list-view.png[][Cross-cluster replicatio * The Elasticsearch version of the local cluster must be the same as or newer than the remote cluster. Refer to {ref}/ccr-overview.html[this document] for more information. +[float] +=== Required permissions + +The `manage` and `manage_ccr` cluster privileges are required to access *Cross-Cluster Replication*. + +You can add these privileges in *Stack Management > Security > Roles*. + [float] [[configure-replication]] === Configure replication diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 25ae29036f656..b53bda95466dc 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -29,6 +29,13 @@ See {ref}/encrypting-communications.html[Encrypting communications]. {kib} and the {ref}/start-basic.html[start basic API] provide a list of all of the features that will no longer be supported if you revert to a basic license. +[float] +=== Required permissions + +The `manage` cluster privilege is required to access *License Management*. + +You can add this privilege in *Stack Management > Security > Roles*. + [discrete] [[update-license]] === Update your license diff --git a/docs/management/managing-remote-clusters.asciidoc b/docs/management/managing-remote-clusters.asciidoc index 83895838efec6..92e0fa822b056 100644 --- a/docs/management/managing-remote-clusters.asciidoc +++ b/docs/management/managing-remote-clusters.asciidoc @@ -11,6 +11,13 @@ To get started, open the menu, then go to *Stack Management > Data > Remote Clus [role="screenshot"] image::images/remote-clusters-list-view.png[Remote Clusters list view, including Add a remote cluster button] +[float] +=== Required permissions + +The `manage` cluster privilege is required to access *Remote Clusters*. + +You can add this privilege in *Stack Management > Security > Roles*. + [float] [[managing-remote-clusters]] === Add a remote cluster diff --git a/docs/management/rollups/create_and_manage_rollups.asciidoc b/docs/management/rollups/create_and_manage_rollups.asciidoc index 8aa57f50fe94b..e20f384b5ed18 100644 --- a/docs/management/rollups/create_and_manage_rollups.asciidoc +++ b/docs/management/rollups/create_and_manage_rollups.asciidoc @@ -20,6 +20,13 @@ image::images/management_rollup_list.png[][List of currently active rollup jobs] Before using this feature, you should be familiar with how rollups work. {ref}/xpack-rollup.html[Rolling up historical data] is a good source for more detailed information. +[float] +=== Required permissions + +The `manage_rollup` cluster privilege is required to access *Rollup jobs*. + +You can add this privilege in *Stack Management > Security > Roles*. + [float] [[create-and-manage-rollup-job]] === Create a rollup job diff --git a/docs/management/upgrade-assistant/index.asciidoc b/docs/management/upgrade-assistant/index.asciidoc index c5fd6a3a555a1..2b8c2da2ef577 100644 --- a/docs/management/upgrade-assistant/index.asciidoc +++ b/docs/management/upgrade-assistant/index.asciidoc @@ -13,6 +13,14 @@ Before you upgrade, make sure that you are using the latest released minor version of {es} to see the most up-to-date deprecation issues. For example, if you want to upgrade to to 7.0, make sure that you are using 6.8. +[float] +=== Required permissions + +The `manage` cluster privilege is required to access the *Upgrade assistant*. +Additional privileges may be needed to perform certain actions. + +You can add this privilege in *Stack Management > Security > Roles*. + [float] === Reindexing diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index b80503750a26e..0cb28ce0fb6e7 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -115,7 +115,7 @@ URL that it derived from the actual server address and `xpack.security.public` s *Impact:* Any workflow that involved manually clearing generated bundles will have to be updated with the new path. -[float]] +[float] === kibana.keystore has moved from the `data` folder to the `config` folder *Details:* By default, kibana.keystore has moved from the configured `path.data` folder to `/config` for archive distributions and `/etc/kibana` for package distributions. If a pre-existing keystore exists in the data directory that path will continue to be used. @@ -136,6 +136,18 @@ custom roles with {kibana-ref}/kibana-privileges.html[{kib} privileges]. instead be assigned the `kibana_admin` role to maintain their current access level. +[float] +=== `kibana_dashboard_only_user` role has been removed. + +*Details:* The `kibana_dashboard_only_user` role has been removed. +If you wish to restrict access to just the Dashboard feature, create +custom roles with {kibana-ref}/kibana-privileges.html[{kib} privileges]. + +*Impact:* Any users currently assigned the `kibana_dashboard_only_user` role will need to be assigned a custom role which only grants access to the Dashboard feature. + +Granting additional cluster or index privileges may enable certain +**Stack Monitoring** features. + [float] [[breaking_80_reporting_changes]] === Reporting changes diff --git a/examples/alerting_example/server/plugin.ts b/examples/alerting_example/server/plugin.ts index e74cad28f77f4..8e246960937ec 100644 --- a/examples/alerting_example/server/plugin.ts +++ b/examples/alerting_example/server/plugin.ts @@ -38,7 +38,7 @@ export class AlertingExamplePlugin implements Plugin { private readonly managementSections = new ManagementSectionsService(); + private readonly appUpdater = new BehaviorSubject(() => ({})); + constructor(private initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup, { home }: ManagementSetupDependencies) { @@ -70,6 +76,7 @@ export class ManagementPlugin implements Plugin section.getAppsEnabled().length > 0); + + if (!hasAnyEnabledApps) { + this.appUpdater.next(() => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + } + return {}; } } diff --git a/test/common/services/security/test_user.ts b/test/common/services/security/test_user.ts index 104094f5b6fb5..83eac78621a53 100644 --- a/test/common/services/security/test_user.ts +++ b/test/common/services/security/test_user.ts @@ -65,9 +65,9 @@ export async function createTestUserService( } return new (class TestUser { - async restoreDefaults() { + async restoreDefaults(shouldRefreshBrowser: boolean = true) { if (isEnabled()) { - await this.setRoles(config.get('security.defaultRoles')); + await this.setRoles(config.get('security.defaultRoles'), shouldRefreshBrowser); } } diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 4c97d672bae2e..057ae0bd13b6e 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -42,6 +42,7 @@ import { FilterBarProvider } from './filter_bar'; import { FlyoutProvider } from './flyout'; import { GlobalNavProvider } from './global_nav'; import { InspectorProvider } from './inspector'; +import { ManagementMenuProvider } from './management'; import { QueryBarProvider } from './query_bar'; import { RemoteProvider } from './remote'; import { RenderableProvider } from './renderable'; @@ -91,4 +92,5 @@ export const services = { savedQueryManagementComponent: SavedQueryManagementComponentProvider, elasticChart: ElasticChartProvider, supertest: KibanaSupertestProvider, + managementMenu: ManagementMenuProvider, }; diff --git a/test/functional/services/management/index.ts b/test/functional/services/management/index.ts new file mode 100644 index 0000000000000..54cd229a8e858 --- /dev/null +++ b/test/functional/services/management/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ManagementMenuProvider } from './management_menu'; diff --git a/test/functional/services/management/management_menu.ts b/test/functional/services/management/management_menu.ts new file mode 100644 index 0000000000000..9aed490bc6998 --- /dev/null +++ b/test/functional/services/management/management_menu.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; + +export function ManagementMenuProvider({ getService }: FtrProviderContext) { + const find = getService('find'); + + class ManagementMenu { + public async getSections() { + const sectionsElements = await find.allByCssSelector( + '.mgtSideBarNav > .euiSideNav__content > .euiSideNavItem' + ); + + const sections = []; + + for (const el of sectionsElements) { + const sectionId = await (await el.findByClassName('euiSideNavItemButton')).getAttribute( + 'data-test-subj' + ); + const sectionLinks = await Promise.all( + (await el.findAllByCssSelector('.euiSideNavItem > a.euiSideNavItemButton')).map((item) => + item.getAttribute('data-test-subj') + ) + ); + + sections.push({ sectionId, sectionLinks }); + } + + return sections; + } + } + + return new ManagementMenu(); +} diff --git a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts index f4363a8e57b37..c2ec5662ad12e 100644 --- a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts @@ -5,7 +5,7 @@ */ import KbnServer from 'src/legacy/server/kbn_server'; -import { Feature, FeatureConfig } from '../../../../plugins/features/server'; +import { KibanaFeature } from '../../../../plugins/features/server'; import { XPackInfo, XPackInfoOptions } from './lib/xpack_info'; export { XPackFeature } from './lib/xpack_info'; diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts index a48124cdbcb6a..14573161b8d5d 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -85,7 +85,9 @@ describe('ensureAuthorized', () => { await actionsAuthorization.ensureAuthorized('create', 'myType'); expect(authorization.actions.savedObject.get).toHaveBeenCalledWith('action', 'create'); - expect(checkPrivileges).toHaveBeenCalledWith(mockAuthorizationAction('action', 'create')); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: mockAuthorizationAction('action', 'create'), + }); expect(auditLogger.actionsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.actionsAuthorizationFailure).not.toHaveBeenCalled(); @@ -131,10 +133,12 @@ describe('ensureAuthorized', () => { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create' ); - expect(checkPrivileges).toHaveBeenCalledWith([ - mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'), - mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'), - ]); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [ + mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'), + mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'), + ], + }); expect(auditLogger.actionsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.actionsAuthorizationFailure).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts index da5a5a1cdc3eb..3ba798ddf1715 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -42,11 +42,11 @@ export class ActionsAuthorization { const { authorization } = this; if (authorization?.mode?.useRbacForRequest(this.request)) { const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); - const { hasAllRequested, username } = await checkPrivileges( - operationAlias[operation] + const { hasAllRequested, username } = await checkPrivileges({ + kibana: operationAlias[operation] ? operationAlias[operation](authorization) - : authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation) - ); + : authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation), + }); if (hasAllRequested) { this.auditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId); } else { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index a6c5899281658..592ca93ef5a16 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -159,7 +159,7 @@ export class ActionsPlugin implements Plugin, Plugi ); } - plugins.features.registerFeature(ACTIONS_FEATURE); + plugins.features.registerKibanaFeature(ACTIONS_FEATURE); setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects); this.eventLogService = plugins.eventLog; diff --git a/x-pack/plugins/alerting_builtins/server/plugin.test.ts b/x-pack/plugins/alerting_builtins/server/plugin.test.ts index 15ad066523502..629c02d923071 100644 --- a/x-pack/plugins/alerting_builtins/server/plugin.test.ts +++ b/x-pack/plugins/alerting_builtins/server/plugin.test.ts @@ -43,7 +43,7 @@ describe('AlertingBuiltins Plugin', () => { "name": "Index threshold", } `); - expect(featuresSetup.registerFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE); }); it('should return a service in the expected shape', async () => { diff --git a/x-pack/plugins/alerting_builtins/server/plugin.ts b/x-pack/plugins/alerting_builtins/server/plugin.ts index 41871c01bfb50..48e5c41cbe637 100644 --- a/x-pack/plugins/alerting_builtins/server/plugin.ts +++ b/x-pack/plugins/alerting_builtins/server/plugin.ts @@ -27,7 +27,7 @@ export class AlertingBuiltinsPlugin implements Plugin { core: CoreSetup, { alerts, features }: AlertingBuiltinsDeps ): Promise { - features.registerFeature(BUILT_IN_ALERTS_FEATURE); + features.registerKibanaFeature(BUILT_IN_ALERTS_FEATURE); registerBuiltInAlertTypes({ service: this.service, diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 6307e463af853..62058d47cbd44 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -306,7 +306,7 @@ In addition, when users are inside your feature you might want to grant them acc You can control all of these abilities by assigning privileges to the Alerting Framework from within your own feature, for example: ```typescript -features.registerFeature({ +features.registerKibanaFeature({ id: 'my-application-id', name: 'My Application', app: [], @@ -348,7 +348,7 @@ In this example we can see the following: It's important to note that any role can be granted a mix of `all` and `read` privileges accross multiple type, for example: ```typescript -features.registerFeature({ +features.registerKibanaFeature({ id: 'my-application-id', name: 'My Application', app: [], diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts index b164d27ded648..c2506381b9df9 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts @@ -6,7 +6,10 @@ import { KibanaRequest } from 'kibana/server'; import { alertTypeRegistryMock } from '../alert_type_registry.mock'; import { securityMock } from '../../../../plugins/security/server/mocks'; -import { PluginStartContract as FeaturesStartContract, Feature } from '../../../features/server'; +import { + PluginStartContract as FeaturesStartContract, + KibanaFeature, +} from '../../../features/server'; import { featuresPluginMock } from '../../../features/server/mocks'; import { AlertsAuthorization, @@ -41,7 +44,7 @@ function mockSecurity() { } function mockFeature(appName: string, typeName?: string) { - return new Feature({ + return new KibanaFeature({ id: appName, name: appName, app: [], @@ -84,7 +87,7 @@ function mockFeature(appName: string, typeName?: string) { } function mockFeatureWithSubFeature(appName: string, typeName: string) { - return new Feature({ + return new KibanaFeature({ id: appName, name: appName, app: [], @@ -174,7 +177,7 @@ beforeEach(() => { async executor() {}, producer: 'myApp', })); - features.getFeatures.mockReturnValue([ + features.getKibanaFeatures.mockReturnValue([ myAppFeature, myOtherAppFeature, myAppWithSubFeature, @@ -255,7 +258,7 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: true, - privileges: [], + privileges: { kibana: [] }, }); await alertAuthorization.ensureAuthorized('myType', 'myApp', WriteOperations.Create); @@ -263,9 +266,9 @@ describe('AlertsAuthorization', () => { expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); expect(authorization.actions.alerting.get).toHaveBeenCalledWith('myType', 'myApp', 'create'); - expect(checkPrivileges).toHaveBeenCalledWith([ - mockAuthorizationAction('myType', 'myApp', 'create'), - ]); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('myType', 'myApp', 'create')], + }); expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); @@ -298,7 +301,7 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: true, - privileges: [], + privileges: { kibana: [] }, }); await alertAuthorization.ensureAuthorized('myType', 'alerts', WriteOperations.Create); @@ -306,9 +309,9 @@ describe('AlertsAuthorization', () => { expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); expect(authorization.actions.alerting.get).toHaveBeenCalledWith('myType', 'myApp', 'create'); - expect(checkPrivileges).toHaveBeenCalledWith([ - mockAuthorizationAction('myType', 'myApp', 'create'), - ]); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('myType', 'myApp', 'create')], + }); expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); @@ -332,7 +335,7 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: true, - privileges: [], + privileges: { kibana: [] }, }); const alertAuthorization = new AlertsAuthorization({ @@ -354,10 +357,12 @@ describe('AlertsAuthorization', () => { 'myOtherApp', 'create' ); - expect(checkPrivileges).toHaveBeenCalledWith([ - mockAuthorizationAction('myType', 'myOtherApp', 'create'), - mockAuthorizationAction('myType', 'myApp', 'create'), - ]); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [ + mockAuthorizationAction('myType', 'myOtherApp', 'create'), + mockAuthorizationAction('myType', 'myApp', 'create'), + ], + }); expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); @@ -390,16 +395,18 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myType', 'myApp', 'create'), - authorized: true, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: true, + }, + ], + }, }); await expect( @@ -439,16 +446,18 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myType', 'myApp', 'create'), - authorized: false, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: false, + }, + ], + }, }); await expect( @@ -488,16 +497,18 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myType', 'myApp', 'create'), - authorized: false, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: false, + }, + ], + }, }); await expect( @@ -592,7 +603,7 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: true, - privileges: [], + privileges: { kibana: [] }, }); const alertAuthorization = new AlertsAuthorization({ @@ -621,24 +632,26 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), - authorized: false, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -680,24 +693,26 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), - authorized: true, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -728,32 +743,34 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('mySecondAppAlertType', 'myApp', 'find'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('mySecondAppAlertType', 'myOtherApp', 'find'), - authorized: true, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('mySecondAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('mySecondAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -903,24 +920,26 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), - authorized: true, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: true, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -989,16 +1008,18 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), - authorized: false, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -1048,40 +1069,42 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'get'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'get'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'get'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'get'), - authorized: true, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'get'), + authorized: true, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ @@ -1158,24 +1181,26 @@ describe('AlertsAuthorization', () => { checkPrivileges.mockResolvedValueOnce({ username: 'some-user', hasAllRequested: false, - privileges: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), - authorized: false, - }, - ], + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + ], + }, }); const alertAuthorization = new AlertsAuthorization({ diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts index b362a50c9f10b..9dda006c1eb8e 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts @@ -82,7 +82,7 @@ export class AlertsAuthorization { (disabledFeatures) => new Set( features - .getFeatures() + .getKibanaFeatures() .filter( ({ id, alerting }) => // ignore features which are disabled in the user's space @@ -133,20 +133,21 @@ export class AlertsAuthorization { const shouldAuthorizeConsumer = consumer !== ALERTS_FEATURE_ID; const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); - const { hasAllRequested, username, privileges } = await checkPrivileges( - shouldAuthorizeConsumer && consumer !== alertType.producer - ? [ - // check for access at consumer level - requiredPrivilegesByScope.consumer, - // check for access at producer level - requiredPrivilegesByScope.producer, - ] - : [ - // skip consumer privilege checks under `alerts` as all alert types can - // be created under `alerts` if you have producer level privileges - requiredPrivilegesByScope.producer, - ] - ); + const { hasAllRequested, username, privileges } = await checkPrivileges({ + kibana: + shouldAuthorizeConsumer && consumer !== alertType.producer + ? [ + // check for access at consumer level + requiredPrivilegesByScope.consumer, + // check for access at producer level + requiredPrivilegesByScope.producer, + ] + : [ + // skip consumer privilege checks under `alerts` as all alert types can + // be created under `alerts` if you have producer level privileges + requiredPrivilegesByScope.producer, + ], + }); if (!isAvailableConsumer) { /** @@ -177,7 +178,7 @@ export class AlertsAuthorization { ); } else { const authorizedPrivileges = map( - privileges.filter((privilege) => privilege.authorized), + privileges.kibana.filter((privilege) => privilege.authorized), 'privilege' ); const unauthorizedScopes = mapValues( @@ -341,9 +342,9 @@ export class AlertsAuthorization { } } - const { username, hasAllRequested, privileges } = await checkPrivileges([ - ...privilegeToAlertType.keys(), - ]); + const { username, hasAllRequested, privileges } = await checkPrivileges({ + kibana: [...privilegeToAlertType.keys()], + }); return { username, @@ -352,7 +353,7 @@ export class AlertsAuthorization { ? // has access to all features this.augmentWithAuthorizedConsumers(alertTypes, await this.allPossibleConsumers) : // only has some of the required privileges - privileges.reduce((authorizedAlertTypes, { authorized, privilege }) => { + privileges.kibana.reduce((authorizedAlertTypes, { authorized, privilege }) => { if (authorized && privilegeToAlertType.has(privilege)) { const [ alertType, diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index e65d195290259..026aa0c5238dc 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -12,7 +12,7 @@ import { taskManagerMock } from '../../task_manager/server/mocks'; import { eventLogServiceMock } from '../../event_log/server/event_log_service.mock'; import { KibanaRequest, CoreSetup } from 'kibana/server'; import { featuresPluginMock } from '../../features/server/mocks'; -import { Feature } from '../../features/server'; +import { KibanaFeature } from '../../features/server'; describe('Alerting Plugin', () => { describe('setup()', () => { @@ -159,8 +159,8 @@ describe('Alerting Plugin', () => { function mockFeatures() { const features = featuresPluginMock.createSetup(); - features.getFeatures.mockReturnValue([ - new Feature({ + features.getKibanaFeatures.mockReturnValue([ + new KibanaFeature({ id: 'appName', name: 'appName', app: [], diff --git a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature index 285615108266b..82d896c5ba17e 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature @@ -1,4 +1,4 @@ -Feature: APM +KibanaFeature: APM Scenario: Transaction duration charts Given a user browses the APM UI application diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index f7e3977ae7d31..f25e37927f094 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -127,7 +127,7 @@ export class APMPlugin implements Plugin { }; }); - plugins.features.registerFeature(APM_FEATURE); + plugins.features.registerKibanaFeature(APM_FEATURE); plugins.licensing.featureUsage.register( APM_SERVICE_MAPS_FEATURE_NAME, APM_SERVICE_MAPS_LICENSE_TYPE diff --git a/x-pack/plugins/beats_management/kibana.json b/x-pack/plugins/beats_management/kibana.json index 3fd1ab6fd8701..c1070eedf07a6 100644 --- a/x-pack/plugins/beats_management/kibana.json +++ b/x-pack/plugins/beats_management/kibana.json @@ -7,7 +7,8 @@ "requiredPlugins": [ "data", "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "security" diff --git a/x-pack/plugins/beats_management/server/plugin.ts b/x-pack/plugins/beats_management/server/plugin.ts index 92c2278148bc1..fde0a2efecdda 100644 --- a/x-pack/plugins/beats_management/server/plugin.ts +++ b/x-pack/plugins/beats_management/server/plugin.ts @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginStart } from '../../licensing/server'; import { BeatsManagementConfigType } from '../common'; @@ -22,6 +23,7 @@ import { beatsIndexTemplate } from './index_templates'; interface SetupDeps { security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } interface StartDeps { @@ -42,7 +44,7 @@ export class BeatsManagementPlugin implements Plugin<{}, {}, SetupDeps, StartDep private readonly initializerContext: PluginInitializerContext ) {} - public async setup(core: CoreSetup, { security }: SetupDeps) { + public async setup(core: CoreSetup, { features, security }: SetupDeps) { this.securitySetup = security; const router = core.http.createRouter(); @@ -52,6 +54,20 @@ export class BeatsManagementPlugin implements Plugin<{}, {}, SetupDeps, StartDep return this.beatsLibs!; }); + features.registerElasticsearchFeature({ + id: 'beats_management', + management: { + ingest: ['beats_management'], + }, + privileges: [ + { + ui: [], + requiredClusterPrivileges: [], + requiredRoles: ['beats_admin'], + }, + ], + }); + return {}; } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index c822ed86cb01c..9a41a00883c13 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -37,7 +37,7 @@ export class CanvasPlugin implements Plugin { coreSetup.savedObjects.registerType(workpadType); coreSetup.savedObjects.registerType(workpadTemplateType); - plugins.features.registerFeature({ + plugins.features.registerKibanaFeature({ id: 'canvas', name: 'Canvas', order: 400, diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index 13746bb0e34c3..292820f81adbe 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -8,7 +8,8 @@ "licensing", "management", "remoteClusters", - "indexManagement" + "indexManagement", + "features" ], "optionalPlugins": [ "usageCollection" diff --git a/x-pack/plugins/cross_cluster_replication/server/plugin.ts b/x-pack/plugins/cross_cluster_replication/server/plugin.ts index e39b4dfd471a8..d40a53f289873 100644 --- a/x-pack/plugins/cross_cluster_replication/server/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/server/plugin.ts @@ -87,7 +87,7 @@ export class CrossClusterReplicationServerPlugin implements Plugin { this.ccrEsClient = this.ccrEsClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/cross_cluster_replication/server/types.ts b/x-pack/plugins/cross_cluster_replication/server/types.ts index c287acf86eb2b..62c96b48c4373 100644 --- a/x-pack/plugins/cross_cluster_replication/server/types.ts +++ b/x-pack/plugins/cross_cluster_replication/server/types.ts @@ -5,6 +5,7 @@ */ import { IRouter } from 'src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; import { RemoteClustersPluginSetup } from '../../remote_clusters/server'; @@ -16,6 +17,7 @@ export interface Dependencies { licensing: LicensingPluginSetup; indexManagement: IndexManagementPluginSetup; remoteClusters: RemoteClustersPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index 0239cb6422d03..497747f953289 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -51,7 +51,7 @@ export const checkAccess = async ({ try { const { hasAllRequested } = await security.authz .checkPrivilegesWithRequest(request) - .globally(security.authz.actions.ui.get('enterpriseSearch', 'all')); + .globally({ kibana: security.authz.actions.ui.get('enterpriseSearch', 'all') }); return hasAllRequested; } catch (err) { if (err.statusCode === 401 || err.statusCode === 403) { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 729a03d24065e..3d28a05a4b7b4 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -78,7 +78,7 @@ export class EnterpriseSearchPlugin implements Plugin { /** * Register space/feature control */ - features.registerFeature({ + features.registerKibanaFeature({ id: ENTERPRISE_SEARCH_PLUGIN.ID, name: ENTERPRISE_SEARCH_PLUGIN.NAME, order: 0, diff --git a/x-pack/plugins/features/common/elasticsearch_feature.ts b/x-pack/plugins/features/common/elasticsearch_feature.ts new file mode 100644 index 0000000000000..4566afc77bd8b --- /dev/null +++ b/x-pack/plugins/features/common/elasticsearch_feature.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureElasticsearchPrivileges } from './feature_elasticsearch_privileges'; + +/** + * Interface for registering an Elasticsearch feature. + * Feature registration allows plugins to hide their applications based + * on configured cluster or index privileges. + */ +export interface ElasticsearchFeatureConfig { + /** + * Unique identifier for this feature. + * This identifier is also used when generating UI Capabilities. + * + * @see UICapabilities + */ + id: string; + + /** + * Management sections associated with this feature. + * + * @example + * ```ts + * // Enables access to the "Advanced Settings" management page within the Kibana section + * management: { + * kibana: ['settings'] + * } + * ``` + */ + management?: { + [sectionId: string]: string[]; + }; + + /** + * If this feature includes a catalogue entry, you can specify them here to control visibility based on the current space. + * + */ + catalogue?: string[]; + + /** + * Feature privilege definition. Specify one or more privileges which grant access to this feature. + * Users must satisfy all privileges in at least one of the defined sets of privileges in order to be granted access. + * + * @example + * ```ts + * [{ + * requiredClusterPrivileges: ['monitor'], + * requiredIndexPrivileges: { + * ['metricbeat-*']: ['read', 'view_index_metadata'] + * } + * }] + * ``` + * @see FeatureElasticsearchPrivileges + */ + privileges: FeatureElasticsearchPrivileges[]; +} + +export class ElasticsearchFeature { + constructor(protected readonly config: RecursiveReadonly) {} + + public get id() { + return this.config.id; + } + + public get catalogue() { + return this.config.catalogue; + } + + public get management() { + return this.config.management; + } + + public get privileges() { + return this.config.privileges; + } + + public toRaw() { + return { ...this.config } as ElasticsearchFeatureConfig; + } +} diff --git a/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts b/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts new file mode 100644 index 0000000000000..1100b2cc648c9 --- /dev/null +++ b/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Elasticsearch Feature privilege definition + */ +export interface FeatureElasticsearchPrivileges { + /** + * A set of Elasticsearch cluster privileges which are required for this feature to be enabled. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/security-privileges.html + * + */ + requiredClusterPrivileges: string[]; + + /** + * A set of Elasticsearch index privileges which are required for this feature to be enabled, keyed on index name or pattern. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/security-privileges.html#privileges-list-indices + * + * @example + * + * Requiring `read` access to `logstash-*` and `all` access to `foo-*` + * ```ts + * feature.registerElasticsearchPrivilege({ + * privileges: [{ + * requiredIndexPrivileges: { + * ['logstash-*']: ['read'], + * ['foo-*]: ['all'] + * } + * }] + * }) + * ``` + * + */ + requiredIndexPrivileges?: { + [indexName: string]: string[]; + }; + + /** + * A set of Elasticsearch roles which are required for this feature to be enabled. + * + * @deprecated do not rely on hard-coded role names. + * + * This is relied on by the reporting feature, and should be removed once reporting + * migrates to using the Kibana Privilege model: https://github.com/elastic/kibana/issues/19914 + */ + requiredRoles?: string[]; + + /** + * A list of UI Capabilities that should be granted to users with this privilege. + * These capabilities will automatically be namespaces within your feature id. + * + * @example + * ```ts + * { + * ui: ['show', 'save'] + * } + * + * This translates in the UI to the following (assuming a feature id of "foo"): + * import { uiCapabilities } from 'ui/capabilities'; + * + * const canShowApp = uiCapabilities.foo.show; + * const canSave = uiCapabilities.foo.save; + * ``` + * Note: Since these are automatically namespaced, you are free to use generic names like "show" and "save". + * + * @see UICapabilities + */ + ui: string[]; +} diff --git a/x-pack/plugins/features/common/index.ts b/x-pack/plugins/features/common/index.ts index e359efbda20d2..a08de2f118712 100644 --- a/x-pack/plugins/features/common/index.ts +++ b/x-pack/plugins/features/common/index.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +export { FeatureElasticsearchPrivileges } from './feature_elasticsearch_privileges'; export { FeatureKibanaPrivileges } from './feature_kibana_privileges'; -export { Feature, FeatureConfig } from './feature'; +export { ElasticsearchFeature, ElasticsearchFeatureConfig } from './elasticsearch_feature'; +export { KibanaFeature, KibanaFeatureConfig } from './kibana_feature'; export { SubFeature, SubFeatureConfig, diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/kibana_feature.ts similarity index 92% rename from x-pack/plugins/features/common/feature.ts rename to x-pack/plugins/features/common/kibana_feature.ts index 1b700fb1a6ad0..a600ada554afd 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -6,7 +6,7 @@ import { RecursiveReadonly } from '@kbn/utility-types'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; -import { SubFeatureConfig, SubFeature } from './sub_feature'; +import { SubFeatureConfig, SubFeature as KibanaSubFeature } from './sub_feature'; import { ReservedKibanaPrivilege } from './reserved_kibana_privilege'; /** @@ -14,7 +14,7 @@ import { ReservedKibanaPrivilege } from './reserved_kibana_privilege'; * Feature registration allows plugins to hide their applications with spaces, * and secure access when configured for security. */ -export interface FeatureConfig { +export interface KibanaFeatureConfig { /** * Unique identifier for this feature. * This identifier is also used when generating UI Capabilities. @@ -137,12 +137,12 @@ export interface FeatureConfig { }; } -export class Feature { - public readonly subFeatures: SubFeature[]; +export class KibanaFeature { + public readonly subFeatures: KibanaSubFeature[]; - constructor(protected readonly config: RecursiveReadonly) { + constructor(protected readonly config: RecursiveReadonly) { this.subFeatures = (config.subFeatures ?? []).map( - (subFeatureConfig) => new SubFeature(subFeatureConfig) + (subFeatureConfig) => new KibanaSubFeature(subFeatureConfig) ); } @@ -199,6 +199,6 @@ export class Feature { } public toRaw() { - return { ...this.config } as FeatureConfig; + return { ...this.config } as KibanaFeatureConfig; } } diff --git a/x-pack/plugins/features/public/features_api_client.ts b/x-pack/plugins/features/public/features_api_client.ts index 50cc54a197f56..cacc623aa853f 100644 --- a/x-pack/plugins/features/public/features_api_client.ts +++ b/x-pack/plugins/features/public/features_api_client.ts @@ -5,13 +5,13 @@ */ import { HttpSetup } from 'src/core/public'; -import { FeatureConfig, Feature } from '.'; +import { KibanaFeatureConfig, KibanaFeature } from '.'; export class FeaturesAPIClient { constructor(private readonly http: HttpSetup) {} public async getFeatures() { - const features = await this.http.get('/api/features'); - return features.map((config) => new Feature(config)); + const features = await this.http.get('/api/features'); + return features.map((config) => new KibanaFeature(config)); } } diff --git a/x-pack/plugins/features/public/index.ts b/x-pack/plugins/features/public/index.ts index f19c7f947d97f..7d86312e466ee 100644 --- a/x-pack/plugins/features/public/index.ts +++ b/x-pack/plugins/features/public/index.ts @@ -8,8 +8,8 @@ import { PluginInitializer } from 'src/core/public'; import { FeaturesPlugin, FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; export { - Feature, - FeatureConfig, + KibanaFeature, + KibanaFeatureConfig, FeatureKibanaPrivileges, SubFeatureConfig, SubFeaturePrivilegeConfig, diff --git a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap index e033b241f9e25..fdeb53dd2fa12 100644 --- a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap @@ -1,27 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FeatureRegistry prevents features from being registered with a catalogue entry of "" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]"`; -exports[`FeatureRegistry prevents features from being registered with a catalogue entry of "contains space" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains space" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; -exports[`FeatureRegistry prevents features from being registered with a catalogue entry of "contains_invalid()_chars" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains_invalid()_chars" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; -exports[`FeatureRegistry prevents features from being registered with a management id of "" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]]"`; -exports[`FeatureRegistry prevents features from being registered with a management id of "contains space" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains space" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; -exports[`FeatureRegistry prevents features from being registered with a management id of "contains_invalid()_chars" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains_invalid()_chars" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; -exports[`FeatureRegistry prevents features from being registered with a navLinkId of "" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" is not allowed to be empty]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" is not allowed to be empty]"`; -exports[`FeatureRegistry prevents features from being registered with a navLinkId of "contains space" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "contains space" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; -exports[`FeatureRegistry prevents features from being registered with a navLinkId of "contains_invalid()_chars" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "contains_invalid()_chars" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; -exports[`FeatureRegistry prevents features from being registered with an ID of "catalogue" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "catalogue" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; -exports[`FeatureRegistry prevents features from being registered with an ID of "doesn't match valid regex" 1`] = `"child \\"id\\" fails because [\\"id\\" with value \\"doesn't match valid regex\\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "doesn't match valid regex" 1`] = `"child \\"id\\" fails because [\\"id\\" with value \\"doesn't match valid regex\\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]"`; -exports[`FeatureRegistry prevents features from being registered with an ID of "management" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "management" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; -exports[`FeatureRegistry prevents features from being registered with an ID of "navLinks" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "navLinks" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index f123068e41758..24aae3a69ee5d 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -5,1192 +5,1389 @@ */ import { FeatureRegistry } from './feature_registry'; -import { FeatureConfig } from '../common/feature'; +import { ElasticsearchFeatureConfig, KibanaFeatureConfig } from '../common'; describe('FeatureRegistry', () => { - it('allows a minimal feature to be registered', () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: null, - }; + describe('Kibana Features', () => { + it('allows a minimal feature to be registered', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + }; - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); - expect(result).toHaveLength(1); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); + expect(result).toHaveLength(1); - // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0].toRaw()).not.toBe(feature); - expect(result[0].toRaw()).toEqual(feature); - }); + // Should be the equal, but not the same instance (i.e., a defensive copy) + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); - it('allows a complex feature to be registered', () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - excludeFromBasePrivileges: true, - icon: 'addDataApp', - navLinkId: 'someNavLink', - app: ['app1'], - validLicenses: ['standard', 'basic', 'gold', 'platinum'], - catalogue: ['foo'], - management: { - foo: ['bar'], - }, - privileges: { - all: { - catalogue: ['foo'], - management: { - foo: ['bar'], - }, - app: ['app1'], - savedObject: { - all: ['space', 'etc', 'telemetry'], - read: ['canvas', 'config', 'url'], - }, - api: ['someApiEndpointTag', 'anotherEndpointTag'], - ui: ['allowsFoo', 'showBar', 'showBaz'], + it('allows a complex feature to be registered', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + excludeFromBasePrivileges: true, + icon: 'addDataApp', + navLinkId: 'someNavLink', + app: ['app1'], + validLicenses: ['standard', 'basic', 'gold', 'platinum'], + catalogue: ['foo'], + management: { + foo: ['bar'], }, - read: { - savedObject: { - all: [], - read: ['config', 'url'], + privileges: { + all: { + catalogue: ['foo'], + management: { + foo: ['bar'], + }, + app: ['app1'], + savedObject: { + all: ['space', 'etc', 'telemetry'], + read: ['canvas', 'config', 'url'], + }, + api: ['someApiEndpointTag', 'anotherEndpointTag'], + ui: ['allowsFoo', 'showBar', 'showBaz'], + }, + read: { + savedObject: { + all: [], + read: ['config', 'url'], + }, + ui: [], }, - ui: [], }, - }, - subFeatures: [ - { - name: 'sub-feature-1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'foo', - name: 'foo', - includeIn: 'read', - savedObject: { - all: [], - read: [], + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'foo', + name: 'foo', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], }, - ui: [], - }, - ], - }, - { - groupType: 'mutually_exclusive', - privileges: [ - { - id: 'bar', - name: 'bar', - includeIn: 'all', - savedObject: { - all: [], - read: [], + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'bar', + name: 'bar', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], }, - ui: [], - }, - { - id: 'baz', - name: 'baz', - includeIn: 'none', - savedObject: { - all: [], - read: [], + { + id: 'baz', + name: 'baz', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], }, - ui: [], + ], + }, + ], + }, + ], + privilegesTooltip: 'some fancy tooltip', + reserved: { + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['foo'], + management: { + foo: ['bar'], }, - ], + app: ['app1'], + savedObject: { + all: ['space', 'etc', 'telemetry'], + read: ['canvas', 'config', 'url'], + }, + api: ['someApiEndpointTag', 'anotherEndpointTag'], + ui: ['allowsFoo', 'showBar', 'showBaz'], + }, }, ], + description: 'some completely adequate description', }, - ], - privilegesTooltip: 'some fancy tooltip', - reserved: { - privileges: [ - { - id: 'reserved', - privilege: { - catalogue: ['foo'], - management: { - foo: ['bar'], - }, - app: ['app1'], - savedObject: { - all: ['space', 'etc', 'telemetry'], - read: ['canvas', 'config', 'url'], - }, - api: ['someApiEndpointTag', 'anotherEndpointTag'], - ui: ['allowsFoo', 'showBar', 'showBaz'], - }, - }, - ], - description: 'some completely adequate description', - }, - }; + }; - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); - expect(result).toHaveLength(1); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); + expect(result).toHaveLength(1); - // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0].toRaw()).not.toBe(feature); - expect(result[0].toRaw()).toEqual(feature); - }); + // Should be the equal, but not the same instance (i.e., a defensive copy) + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); - it(`requires a value for privileges`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - } as any; + it(`requires a value for privileges`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + } as any; - const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` - ); - }); + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + ); + }); - it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: null, - subFeatures: [ - { - name: 'my sub feature', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'my-sub-priv', - name: 'my sub priv', - includeIn: 'none', - savedObject: { - all: [], - read: [], + it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'my-sub-priv', + name: 'my sub priv', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], }, - ui: [], - }, - ], - }, - ], - }, - ], - }; + ], + }, + ], + }, + ], + }; - const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` - ); - }); + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` + ); + }); - it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: { - all: { - ui: [], - savedObject: { - all: [], - read: [], + it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: { + all: { + ui: [], + savedObject: { + all: [], + read: [], + }, }, - }, - read: { - ui: [], - savedObject: { - all: [], - read: [], + read: { + ui: [], + savedObject: { + all: [], + read: [], + }, }, }, - }, - }; + }; - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); - expect(result[0].privileges).toHaveProperty('all'); - expect(result[0].privileges).toHaveProperty('read'); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); - const allPrivilege = result[0].privileges?.all; - expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); - }); + const allPrivilege = result[0].privileges?.all; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); + }); - it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: { - all: { - ui: [], - savedObject: { - all: [], - read: [], + it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: { + all: { + ui: [], + savedObject: { + all: [], + read: [], + }, }, - }, - read: { - ui: [], - savedObject: { - all: [], - read: [], + read: { + ui: [], + savedObject: { + all: [], + read: [], + }, }, }, - }, - }; + }; - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); - expect(result[0].privileges).toHaveProperty('all'); - expect(result[0].privileges).toHaveProperty('read'); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); - const allPrivilege = result[0].privileges?.all; - const readPrivilege = result[0].privileges?.read; - expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); - }); + const allPrivilege = result[0].privileges?.all; + const readPrivilege = result[0].privileges?.read; + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + }); - it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: null, - reserved: { - description: 'foo', - privileges: [ - { - id: 'reserved', - privilege: { - ui: [], - savedObject: { - all: [], - read: [], + it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved', + privilege: { + ui: [], + savedObject: { + all: [], + read: [], + }, }, }, - }, - ], - }, - }; + ], + }, + }; - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); - const reservedPrivilege = result[0]!.reserved!.privileges[0].privilege; - expect(reservedPrivilege.savedObject.all).toEqual(['telemetry']); - expect(reservedPrivilege.savedObject.read).toEqual(['config', 'url']); - }); + const reservedPrivilege = result[0]!.reserved!.privileges[0].privilege; + expect(reservedPrivilege.savedObject.all).toEqual(['telemetry']); + expect(reservedPrivilege.savedObject.read).toEqual(['config', 'url']); + }); - it(`does not duplicate the automatic grants if specified on the incoming feature`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: { - all: { - ui: [], - savedObject: { - all: ['telemetry'], - read: ['config', 'url'], + it(`does not duplicate the automatic grants if specified on the incoming feature`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: { + all: { + ui: [], + savedObject: { + all: ['telemetry'], + read: ['config', 'url'], + }, }, - }, - read: { - ui: [], - savedObject: { - all: [], - read: ['config', 'url'], + read: { + ui: [], + savedObject: { + all: [], + read: ['config', 'url'], + }, }, }, - }, - }; - - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + }; - expect(result[0].privileges).toHaveProperty('all'); - expect(result[0].privileges).toHaveProperty('read'); - - const allPrivilege = result[0].privileges!.all; - const readPrivilege = result[0].privileges!.read; - expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); - expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); - }); - - it(`does not allow duplicate features to be registered`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: null, - }; + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); - const duplicateFeature: FeatureConfig = { - id: 'test-feature', - name: 'Duplicate Test Feature', - app: [], - privileges: null, - }; + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); + const allPrivilege = result[0].privileges!.all; + const readPrivilege = result[0].privileges!.read; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + }); - expect(() => featureRegistry.register(duplicateFeature)).toThrowErrorMatchingInlineSnapshot( - `"Feature with id test-feature is already registered."` - ); - }); + it(`does not allow duplicate features to be registered`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + }; + + const duplicateFeature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Duplicate Test Feature', + app: [], + privileges: null, + }; - ['contains space', 'contains_invalid()_chars', ''].forEach((prohibitedChars) => { - it(`prevents features from being registered with a navLinkId of "${prohibitedChars}"`, () => { const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + expect(() => - featureRegistry.register({ - id: 'foo', - name: 'some feature', - navLinkId: prohibitedChars, - app: [], - privileges: null, - }) - ).toThrowErrorMatchingSnapshot(); + featureRegistry.registerKibanaFeature(duplicateFeature) + ).toThrowErrorMatchingInlineSnapshot(`"Feature with id test-feature is already registered."`); }); - it(`prevents features from being registered with a management id of "${prohibitedChars}"`, () => { - const featureRegistry = new FeatureRegistry(); - expect(() => - featureRegistry.register({ - id: 'foo', - name: 'some feature', - management: { - kibana: [prohibitedChars], - }, - app: [], - privileges: null, - }) - ).toThrowErrorMatchingSnapshot(); + ['contains space', 'contains_invalid()_chars', ''].forEach((prohibitedChars) => { + it(`prevents features from being registered with a navLinkId of "${prohibitedChars}"`, () => { + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature({ + id: 'foo', + name: 'some feature', + navLinkId: prohibitedChars, + app: [], + privileges: null, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it(`prevents features from being registered with a management id of "${prohibitedChars}"`, () => { + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature({ + id: 'foo', + name: 'some feature', + management: { + kibana: [prohibitedChars], + }, + app: [], + privileges: null, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it(`prevents features from being registered with a catalogue entry of "${prohibitedChars}"`, () => { + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature({ + id: 'foo', + name: 'some feature', + catalogue: [prohibitedChars], + app: [], + privileges: null, + }) + ).toThrowErrorMatchingSnapshot(); + }); }); - it(`prevents features from being registered with a catalogue entry of "${prohibitedChars}"`, () => { - const featureRegistry = new FeatureRegistry(); - expect(() => - featureRegistry.register({ - id: 'foo', - name: 'some feature', - catalogue: [prohibitedChars], - app: [], - privileges: null, - }) - ).toThrowErrorMatchingSnapshot(); + ['catalogue', 'management', 'navLinks', `doesn't match valid regex`].forEach((prohibitedId) => { + it(`prevents features from being registered with an ID of "${prohibitedId}"`, () => { + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature({ + id: prohibitedId, + name: 'some feature', + app: [], + privileges: null, + }) + ).toThrowErrorMatchingSnapshot(); + }); }); - }); - ['catalogue', 'management', 'navLinks', `doesn't match valid regex`].forEach((prohibitedId) => { - it(`prevents features from being registered with an ID of "${prohibitedId}"`, () => { + it('prevents features from being registered with invalid privilege names', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['app1', 'app2'], + privileges: { + foo: { + name: 'Foo', + app: ['app1', 'app2'], + savedObject: { + all: ['config', 'space', 'etc'], + read: ['canvas'], + }, + api: ['someApiEndpointTag', 'anotherEndpointTag'], + ui: ['allowsFoo', 'showBar', 'showBaz'], + }, + } as any, + }; + const featureRegistry = new FeatureRegistry(); expect(() => - featureRegistry.register({ - id: prohibitedId, - name: 'some feature', - app: [], - privileges: null, - }) - ).toThrowErrorMatchingSnapshot(); + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"foo\\" is not allowed]"` + ); }); - }); - it('prevents features from being registered with invalid privilege names', () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: ['app1', 'app2'], - privileges: { - foo: { - name: 'Foo', - app: ['app1', 'app2'], - savedObject: { - all: ['config', 'space', 'etc'], - read: ['canvas'], + it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['bar'], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], }, - api: ['someApiEndpointTag', 'anotherEndpointTag'], - ui: ['allowsFoo', 'showBar', 'showBaz'], }, - } as any, - }; + }; - const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"child \\"privileges\\" fails because [\\"foo\\" is not allowed]"` - ); - }); + const featureRegistry = new FeatureRegistry(); - it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: ['bar'], - privileges: { - all: { - savedObject: { - all: [], - read: [], + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown app entries: foo, baz"` + ); + }); + + it(`prevents features from specifying app entries that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['bar'], }, - ui: [], - app: ['foo', 'bar', 'baz'], - }, - read: { - savedObject: { - all: [], - read: [], + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: ['foo', 'bar', 'baz'], }, - }, - }; + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo'], + }, + ], + }, + ], + }, + ], + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.all has unknown app entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying app entries that don't exist at the privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: ['foo', 'bar', 'baz'], - privileges: { - all: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: ['bar'], - }, - read: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - }, - subFeatures: [ - { - name: 'my sub feature', - privilegeGroups: [ + it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ { - groupType: 'independent', - privileges: [ - { - id: 'cool-sub-feature-privilege', - name: 'cool privilege', - includeIn: 'none', - savedObject: { - all: [], - read: [], - }, - ui: [], - app: ['foo'], + id: 'reserved', + privilege: { + savedObject: { + all: [], + read: [], }, - ], + ui: [], + app: ['foo', 'bar', 'baz'], + }, }, ], }, - ], - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown app entries: foo, baz"` + ); + }); - it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: ['bar'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - savedObject: { - all: [], - read: [], + it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar'], }, - ui: [], - app: ['foo', 'bar', 'baz'], }, - }, - ], - }, - }; + ], + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.reserved has unknown app entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: ['foo', 'bar', 'baz'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: ['foo', 'bar'], + it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + privileges: { + all: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], }, + ui: [], + app: [], }, - ], - }, - }; + read: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown catalogue entries: foo, baz"` + ); + }); - it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - privileges: { - all: { - catalogue: ['foo', 'bar', 'baz'], - savedObject: { - all: [], - read: [], + it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: { + all: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], - }, - read: { - catalogue: ['foo', 'bar', 'baz'], - savedObject: { - all: [], - read: [], + read: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], }, - }, - }; + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + catalogue: ['bar'], + }, + ], + }, + ], + }, + ], + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.all has unknown catalogue entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['foo', 'bar', 'baz'], - privileges: { - all: { - catalogue: ['foo'], - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - read: { - catalogue: ['foo'], - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - }, - subFeatures: [ - { - name: 'my sub feature', - privilegeGroups: [ + it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ { - groupType: 'independent', - privileges: [ - { - id: 'cool-sub-feature-privilege', - name: 'cool privilege', - includeIn: 'none', - savedObject: { - all: [], - read: [], - }, - ui: [], - catalogue: ['bar'], + id: 'reserved', + privilege: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], }, - ], + ui: [], + app: [], + }, }, ], }, - ], - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown catalogue entries: foo, baz"` + ); + }); - it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - catalogue: ['foo', 'bar', 'baz'], - savedObject: { - all: [], - read: [], + it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['foo', 'bar'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], }, - }, - ], - }, - }; + ], + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.reserved has unknown catalogue entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['foo', 'bar', 'baz'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - catalogue: ['foo', 'bar'], - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], + it(`prevents privileges from specifying alerting entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['bar'], + privileges: { + all: { + alerting: { + all: ['foo', 'bar'], + read: ['baz'], + }, + savedObject: { + all: [], + read: [], }, + ui: [], + app: [], }, - ], - }, - }; + read: { + alerting: { read: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown alerting entries: foo, baz"` + ); + }); - it(`prevents privileges from specifying alerting entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - alerting: ['bar'], - privileges: { - all: { - alerting: { - all: ['foo', 'bar'], - read: ['baz'], + it(`prevents features from specifying alerting entries that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['foo', 'bar', 'baz'], + privileges: { + all: { + alerting: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - savedObject: { - all: [], - read: [], + read: { + alerting: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], }, - read: { - alerting: { read: ['foo', 'bar', 'baz'] }, - savedObject: { - all: [], - read: [], + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + alerting: { all: ['bar'] }, + }, + ], + }, + ], }, - ui: [], - app: [], - }, - }, - }; + ], + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.all has unknown alerting entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying alerting entries that don't exist at the privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - alerting: ['foo', 'bar', 'baz'], - privileges: { - all: { - alerting: { all: ['foo'] }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - read: { - alerting: { all: ['foo'] }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - }, - subFeatures: [ - { - name: 'my sub feature', - privilegeGroups: [ + it(`prevents reserved privileges from specifying alerting entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ { - groupType: 'independent', - privileges: [ - { - id: 'cool-sub-feature-privilege', - name: 'cool privilege', - includeIn: 'none', - savedObject: { - all: [], - read: [], - }, - ui: [], - alerting: { all: ['bar'] }, + id: 'reserved', + privilege: { + alerting: { all: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], }, - ], + ui: [], + app: [], + }, }, ], }, - ], - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown alerting entries: foo, baz"` + ); + }); - it(`prevents reserved privileges from specifying alerting entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - alerting: ['bar'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - alerting: { all: ['foo', 'bar', 'baz'] }, - savedObject: { - all: [], - read: [], + it(`prevents features from specifying alerting entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + alerting: { all: ['foo', 'bar'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], }, - }, - ], - }, - }; + ], + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.reserved has unknown alerting entries: foo, baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); - it(`prevents features from specifying alerting entries that don't exist at the reserved privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - alerting: ['foo', 'bar', 'baz'], - privileges: null, - reserved: { - description: 'something', - privileges: [ - { - id: 'reserved', - privilege: { - alerting: { all: ['foo', 'bar'] }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], + }, + privileges: { + all: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], }, + ui: [], + app: [], }, - ], - }, - }; + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown management section: elasticsearch"` + ); + }); - it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - management: { - kibana: ['hey'], - }, - privileges: { - all: { - catalogue: ['bar'], - management: { - elasticsearch: ['hey'], + it(`prevents features from specifying management sections that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], + elasticsearch: ['hey', 'there'], + }, + privileges: { + all: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - savedObject: { - all: [], - read: [], + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], }, - ui: [], - app: [], }, - read: { - catalogue: ['bar'], - management: { - elasticsearch: ['hey'], - }, - savedObject: { - all: [], - read: [], + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + management: { + kibana: ['hey'], + elasticsearch: ['hey'], + }, + }, + ], + }, + ], }, - ui: [], - app: [], + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` + ); + }); + + it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], }, - }, - }; + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.all has unknown management section: elasticsearch"` - ); - }); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown management entries for section kibana: hey-there"` + ); + }); - it(`prevents features from specifying management sections that don't exist at the privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - management: { - kibana: ['hey'], - elasticsearch: ['hey', 'there'], - }, - privileges: { - all: { - catalogue: ['bar'], - management: { - elasticsearch: ['hey'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], + it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey', 'hey-there'], }, - read: { - catalogue: ['bar'], - management: { - elasticsearch: ['hey'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], }, - }, - subFeatures: [ - { - name: 'my sub feature', - privilegeGroups: [ + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` + ); + }); + + it('allows multiple reserved feature privileges to be registered', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + reserved: { + description: 'my reserved privileges', + privileges: [ { - groupType: 'independent', - privileges: [ - { - id: 'cool-sub-feature-privilege', - name: 'cool privilege', - includeIn: 'none', - savedObject: { - all: [], - read: [], - }, - ui: [], - management: { - kibana: ['hey'], - elasticsearch: ['hey'], - }, + id: 'a_reserved_1', + privilege: { + savedObject: { + all: [], + read: [], }, - ], + ui: [], + app: [], + }, + }, + { + id: 'a_reserved_2', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, ], }, - ], - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); + expect(result).toHaveLength(1); + expect(result[0].reserved?.privileges).toHaveLength(2); + }); + + it('does not allow reserved privilege ids to start with "reserved_"', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + reserved: { + description: 'my reserved privileges', + privileges: [ + { + id: 'reserved_1', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` - ); + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"reserved\\" fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"id\\" fails because [\\"id\\" with value \\"reserved_1\\" fails to match the required pattern: /^(?!reserved_)[a-zA-Z0-9_-]+$/]]]]"` + ); + }); + + it('cannot register feature after getAll has been called', () => { + const feature1: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + }; + const feature2: KibanaFeatureConfig = { + id: 'test-feature-2', + name: 'Test Feature 2', + app: [], + privileges: null, + }; + + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature1); + featureRegistry.getAllKibanaFeatures(); + expect(() => { + featureRegistry.registerKibanaFeature(feature2); + }).toThrowErrorMatchingInlineSnapshot( + `"Features are locked, can't register new features. Attempt to register test-feature-2 failed."` + ); + }); }); - it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - management: { - kibana: ['hey'], - }, - privileges: null, - reserved: { - description: 'something', + describe('Elasticsearch Features', () => { + it('allows a minimal feature to be registered', () => { + const feature: ElasticsearchFeatureConfig = { + id: 'test-feature', privileges: [ { - id: 'reserved', - privilege: { - catalogue: ['bar'], - management: { - kibana: ['hey-there'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, + requiredClusterPrivileges: ['all'], + ui: [], }, ], - }, - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerElasticsearchFeature(feature); + const result = featureRegistry.getAllElasticsearchFeatures(); + expect(result).toHaveLength(1); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature privilege test-feature.reserved has unknown management entries for section kibana: hey-there"` - ); - }); + // Should be the equal, but not the same instance (i.e., a defensive copy) + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); - it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - catalogue: ['bar'], - management: { - kibana: ['hey', 'hey-there'], - }, - privileges: null, - reserved: { - description: 'something', + it('allows a complex feature to ge registered', () => { + const feature: ElasticsearchFeatureConfig = { + id: 'test-feature', + management: { + kibana: ['foo'], + data: ['bar'], + }, + catalogue: ['foo', 'bar'], privileges: [ { - id: 'reserved', - privilege: { - catalogue: ['bar'], - management: { - kibana: ['hey-there'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], + requiredClusterPrivileges: ['monitor', 'manage'], + requiredIndexPrivileges: { + foo: ['read'], + bar: ['all'], + baz: ['view_index_metadata'], }, + ui: ['ui_a'], + }, + { + requiredClusterPrivileges: [], + requiredRoles: ['some_role'], + ui: ['ui_b'], }, ], - }, - }; + }; - const featureRegistry = new FeatureRegistry(); + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerElasticsearchFeature(feature); + const result = featureRegistry.getAllElasticsearchFeatures(); + expect(result).toHaveLength(1); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` - ); - }); + // Should be the equal, but not the same instance (i.e., a defensive copy) + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); - it('allows multiple reserved feature privileges to be registered', () => { - const feature: FeatureConfig = { - id: 'test-feature', - name: 'Test Feature', - app: [], - privileges: null, - reserved: { - description: 'my reserved privileges', + it('requires a value for privileges', () => { + const feature: ElasticsearchFeatureConfig = { + id: 'test-feature', + } as any; + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerElasticsearchFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + ); + }); + + it('requires privileges to declare some form of required es privileges', () => { + const feature: ElasticsearchFeatureConfig = { + id: 'test-feature', privileges: [ { - id: 'a_reserved_1', - privilege: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, + ui: [], }, + ], + } as any; + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerElasticsearchFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature has a privilege definition at index 0 without any privileges defined."` + ); + }); + + it('does not allow duplicate privilege ids', () => { + const feature: ElasticsearchFeatureConfig = { + id: 'test-feature', + privileges: [ { - id: 'a_reserved_2', - privilege: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, + requiredClusterPrivileges: ['all'], + ui: [], }, ], - }, - }; - - const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); - expect(result).toHaveLength(1); - expect(result[0].reserved?.privileges).toHaveLength(2); + }; + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerElasticsearchFeature(feature); + expect(() => + featureRegistry.registerElasticsearchFeature(feature) + ).toThrowErrorMatchingInlineSnapshot(`"Feature with id test-feature is already registered."`); + }); }); - it('does not allow reserved privilege ids to start with "reserved_"', () => { - const feature: FeatureConfig = { + it('does not allow a Kibana feature to share an id with an Elasticsearch feature', () => { + const kibanaFeature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], privileges: null, - reserved: { - description: 'my reserved privileges', - privileges: [ - { - id: 'reserved_1', - privilege: { - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, - }, - ], - }, + }; + + const elasticsearchFeature: ElasticsearchFeatureConfig = { + id: 'test-feature', + privileges: [ + { + requiredClusterPrivileges: ['all'], + ui: [], + }, + ], }; const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( - `"child \\"reserved\\" fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"id\\" fails because [\\"id\\" with value \\"reserved_1\\" fails to match the required pattern: /^(?!reserved_)[a-zA-Z0-9_-]+$/]]]]"` - ); + featureRegistry.registerElasticsearchFeature(elasticsearchFeature); + expect(() => + featureRegistry.registerKibanaFeature(kibanaFeature) + ).toThrowErrorMatchingInlineSnapshot(`"Feature with id test-feature is already registered."`); }); - it('cannot register feature after getAll has been called', () => { - const feature1: FeatureConfig = { + it('does not allow an Elasticsearch feature to share an id with a Kibana feature', () => { + const kibanaFeature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], privileges: null, }; - const feature2: FeatureConfig = { - id: 'test-feature-2', - name: 'Test Feature 2', - app: [], - privileges: null, + + const elasticsearchFeature: ElasticsearchFeatureConfig = { + id: 'test-feature', + privileges: [ + { + requiredClusterPrivileges: ['all'], + ui: [], + }, + ], }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature1); - featureRegistry.getAll(); - expect(() => { - featureRegistry.register(feature2); - }).toThrowErrorMatchingInlineSnapshot( - `"Features are locked, can't register new features. Attempt to register test-feature-2 failed."` - ); + featureRegistry.registerKibanaFeature(kibanaFeature); + expect(() => + featureRegistry.registerElasticsearchFeature(elasticsearchFeature) + ).toThrowErrorMatchingInlineSnapshot(`"Feature with id test-feature is already registered."`); }); }); diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 12aafd226f754..d357bdb782797 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,38 +5,72 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common'; -import { validateFeature } from './feature_schema'; +import { + KibanaFeatureConfig, + KibanaFeature, + FeatureKibanaPrivileges, + ElasticsearchFeatureConfig, + ElasticsearchFeature, +} from '../common'; +import { validateKibanaFeature, validateElasticsearchFeature } from './feature_schema'; export class FeatureRegistry { private locked = false; - private features: Record = {}; + private kibanaFeatures: Record = {}; + private esFeatures: Record = {}; - public register(feature: FeatureConfig) { + public registerKibanaFeature(feature: KibanaFeatureConfig) { if (this.locked) { throw new Error( `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` ); } - validateFeature(feature); + validateKibanaFeature(feature); - if (feature.id in this.features) { + if (feature.id in this.kibanaFeatures || feature.id in this.esFeatures) { throw new Error(`Feature with id ${feature.id} is already registered.`); } const featureCopy = cloneDeep(feature); - this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); + this.kibanaFeatures[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); } - public getAll(): Feature[] { + public registerElasticsearchFeature(feature: ElasticsearchFeatureConfig) { + if (this.locked) { + throw new Error( + `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` + ); + } + + if (feature.id in this.kibanaFeatures || feature.id in this.esFeatures) { + throw new Error(`Feature with id ${feature.id} is already registered.`); + } + + validateElasticsearchFeature(feature); + + const featureCopy = cloneDeep(feature); + + this.esFeatures[feature.id] = featureCopy; + } + + public getAllKibanaFeatures(): KibanaFeature[] { + this.locked = true; + return Object.values(this.kibanaFeatures).map( + (featureConfig) => new KibanaFeature(featureConfig) + ); + } + + public getAllElasticsearchFeatures(): ElasticsearchFeature[] { this.locked = true; - return Object.values(this.features).map((featureConfig) => new Feature(featureConfig)); + return Object.values(this.esFeatures).map( + (featureConfig) => new ElasticsearchFeature(featureConfig) + ); } } -function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig { +function applyAutomaticPrivilegeGrants(feature: KibanaFeatureConfig): KibanaFeatureConfig { const allPrivilege = feature.privileges?.all; const readPrivilege = feature.privileges?.read; const reservedPrivileges = (feature.reserved?.privileges ?? []).map((rp) => rp.privilege); diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 95298603d706a..06a3eb158d99d 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -8,8 +8,8 @@ import Joi from 'joi'; import { difference } from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { FeatureConfig } from '../common/feature'; -import { FeatureKibanaPrivileges } from '.'; +import { KibanaFeatureConfig } from '../common'; +import { FeatureKibanaPrivileges, ElasticsearchFeatureConfig } from '.'; // Each feature gets its own property on the UICapabilities object, // but that object has a few built-in properties which should not be overwritten. @@ -28,7 +28,7 @@ const managementSchema = Joi.object().pattern( const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)); const alertingSchema = Joi.array().items(Joi.string()); -const privilegeSchema = Joi.object({ +const kibanaPrivilegeSchema = Joi.object({ excludeFromBasePrivileges: Joi.boolean(), management: managementSchema, catalogue: catalogueSchema, @@ -45,7 +45,7 @@ const privilegeSchema = Joi.object({ ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), }); -const subFeaturePrivilegeSchema = Joi.object({ +const kibanaSubFeaturePrivilegeSchema = Joi.object({ id: Joi.string().regex(subFeaturePrivilegePartRegex).required(), name: Joi.string().required(), includeIn: Joi.string().allow('all', 'read', 'none').required(), @@ -64,17 +64,17 @@ const subFeaturePrivilegeSchema = Joi.object({ ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), }); -const subFeatureSchema = Joi.object({ +const kibanaSubFeatureSchema = Joi.object({ name: Joi.string().required(), privilegeGroups: Joi.array().items( Joi.object({ groupType: Joi.string().valid('mutually_exclusive', 'independent').required(), - privileges: Joi.array().items(subFeaturePrivilegeSchema).min(1), + privileges: Joi.array().items(kibanaSubFeaturePrivilegeSchema).min(1), }) ), }); -const schema = Joi.object({ +const kibanaFeatureSchema = Joi.object({ id: Joi.string() .regex(featurePrivilegePartRegex) .invalid(...prohibitedFeatureIds) @@ -93,15 +93,15 @@ const schema = Joi.object({ catalogue: catalogueSchema, alerting: alertingSchema, privileges: Joi.object({ - all: privilegeSchema, - read: privilegeSchema, + all: kibanaPrivilegeSchema, + read: kibanaPrivilegeSchema, }) .allow(null) .required(), subFeatures: Joi.when('privileges', { is: null, - then: Joi.array().items(subFeatureSchema).max(0), - otherwise: Joi.array().items(subFeatureSchema), + then: Joi.array().items(kibanaSubFeatureSchema).max(0), + otherwise: Joi.array().items(kibanaSubFeatureSchema), }), privilegesTooltip: Joi.string(), reserved: Joi.object({ @@ -110,15 +110,32 @@ const schema = Joi.object({ .items( Joi.object({ id: Joi.string().regex(reservedFeaturePrrivilegePartRegex).required(), - privilege: privilegeSchema.required(), + privilege: kibanaPrivilegeSchema.required(), }) ) .required(), }), }); -export function validateFeature(feature: FeatureConfig) { - const validateResult = Joi.validate(feature, schema); +const elasticsearchPrivilegeSchema = Joi.object({ + ui: Joi.array().items(Joi.string()).required(), + requiredClusterPrivileges: Joi.array().items(Joi.string()), + requiredIndexPrivileges: Joi.object().pattern(Joi.string(), Joi.array().items(Joi.string())), + requiredRoles: Joi.array().items(Joi.string()), +}); + +const elasticsearchFeatureSchema = Joi.object({ + id: Joi.string() + .regex(featurePrivilegePartRegex) + .invalid(...prohibitedFeatureIds) + .required(), + management: managementSchema, + catalogue: catalogueSchema, + privileges: Joi.array().items(elasticsearchPrivilegeSchema).required(), +}); + +export function validateKibanaFeature(feature: KibanaFeatureConfig) { + const validateResult = Joi.validate(feature, kibanaFeatureSchema); if (validateResult.error) { throw validateResult.error; } @@ -303,3 +320,29 @@ export function validateFeature(feature: FeatureConfig) { ); } } + +export function validateElasticsearchFeature(feature: ElasticsearchFeatureConfig) { + const validateResult = Joi.validate(feature, elasticsearchFeatureSchema); + if (validateResult.error) { + throw validateResult.error; + } + // the following validation can't be enforced by the Joi schema without a very convoluted and verbose definition + const { privileges } = feature; + privileges.forEach((privilege, index) => { + const { + requiredClusterPrivileges = [], + requiredIndexPrivileges = [], + requiredRoles = [], + } = privilege; + + if ( + requiredClusterPrivileges.length === 0 && + requiredIndexPrivileges.length === 0 && + requiredRoles.length === 0 + ) { + throw new Error( + `Feature ${feature.id} has a privilege definition at index ${index} without any privileges defined.` + ); + } + }); +} diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 48a350ae8f8fd..28c0fee041594 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -13,7 +13,14 @@ import { Plugin } from './plugin'; // run-time contracts. export { uiCapabilitiesRegex } from './feature_schema'; -export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common'; +export { + KibanaFeature, + KibanaFeatureConfig, + FeatureKibanaPrivileges, + ElasticsearchFeature, + ElasticsearchFeatureConfig, + FeatureElasticsearchPrivileges, +} from '../common'; export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/features/server/mocks.ts b/x-pack/plugins/features/server/mocks.ts index d9437169a7453..91c297c50e462 100644 --- a/x-pack/plugins/features/server/mocks.ts +++ b/x-pack/plugins/features/server/mocks.ts @@ -8,15 +8,18 @@ import { PluginSetupContract, PluginStartContract } from './plugin'; const createSetup = (): jest.Mocked => { return { - getFeatures: jest.fn(), + getKibanaFeatures: jest.fn(), + getElasticsearchFeatures: jest.fn(), getFeaturesUICapabilities: jest.fn(), - registerFeature: jest.fn(), + registerKibanaFeature: jest.fn(), + registerElasticsearchFeature: jest.fn(), }; }; const createStart = (): jest.Mocked => { return { - getFeatures: jest.fn(), + getKibanaFeatures: jest.fn(), + getElasticsearchFeatures: jest.fn(), }; }; diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index c38f2afc88389..961656aba8bfd 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -6,7 +6,7 @@ import { buildOSSFeatures } from './oss_features'; import { featurePrivilegeIterator } from '../../security/server/authorization'; -import { Feature } from '.'; +import { KibanaFeature } from '.'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { @@ -48,7 +48,7 @@ Array [ features.forEach((featureConfig) => { it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { const privileges = []; - for (const featurePrivilege of featurePrivilegeIterator(new Feature(featureConfig), { + for (const featurePrivilege of featurePrivilegeIterator(new KibanaFeature(featureConfig), { augmentWithSubFeaturePrivileges: true, })) { privileges.push(featurePrivilege); diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 4122c590e74b1..3ff6b1b7bf44f 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { FeatureConfig } from '../common/feature'; +import { KibanaFeatureConfig } from '../common'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; @@ -368,10 +368,10 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, }, ...(includeTimelion ? [timelionFeature] : []), - ] as FeatureConfig[]; + ] as KibanaFeatureConfig[]; }; -const timelionFeature: FeatureConfig = { +const timelionFeature: KibanaFeatureConfig = { id: 'timelion', name: 'Timelion', order: 350, diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index 00d578f5ca866..ee11e0e2bbe2e 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -28,19 +28,19 @@ describe('Features Plugin', () => { coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); }); - it('returns OSS + registered features', async () => { + it('returns OSS + registered kibana features', async () => { const plugin = new Plugin(initContext); - const { registerFeature } = await plugin.setup(coreSetup, {}); - registerFeature({ + const { registerKibanaFeature } = await plugin.setup(coreSetup, {}); + registerKibanaFeature({ id: 'baz', name: 'baz', app: [], privileges: null, }); - const { getFeatures } = await plugin.start(coreStart); + const { getKibanaFeatures } = plugin.start(coreStart); - expect(getFeatures().map((f) => f.id)).toMatchInlineSnapshot(` + expect(getKibanaFeatures().map((f) => f.id)).toMatchInlineSnapshot(` Array [ "baz", "discover", @@ -54,9 +54,9 @@ describe('Features Plugin', () => { `); }); - it('returns OSS + registered features with timelion when available', async () => { + it('returns OSS + registered kibana features with timelion when available', async () => { const plugin = new Plugin(initContext); - const { registerFeature } = await plugin.setup(coreSetup, { + const { registerKibanaFeature: registerFeature } = await plugin.setup(coreSetup, { visTypeTimelion: { uiEnabled: true }, }); registerFeature({ @@ -66,9 +66,9 @@ describe('Features Plugin', () => { privileges: null, }); - const { getFeatures } = await plugin.start(coreStart); + const { getKibanaFeatures } = plugin.start(coreStart); - expect(getFeatures().map((f) => f.id)).toMatchInlineSnapshot(` + expect(getKibanaFeatures().map((f) => f.id)).toMatchInlineSnapshot(` Array [ "baz", "discover", @@ -83,19 +83,41 @@ describe('Features Plugin', () => { `); }); - it('registers not hidden saved objects types', async () => { + it('registers kibana features with not hidden saved objects types', async () => { const plugin = new Plugin(initContext); await plugin.setup(coreSetup, {}); - const { getFeatures } = await plugin.start(coreStart); + const { getKibanaFeatures } = plugin.start(coreStart); const soTypes = - getFeatures().find((f) => f.id === 'savedObjectsManagement')?.privileges?.all.savedObject - .all || []; + getKibanaFeatures().find((f) => f.id === 'savedObjectsManagement')?.privileges?.all + .savedObject.all || []; expect(soTypes.includes('foo')).toBe(true); expect(soTypes.includes('bar')).toBe(false); }); + it('returns registered elasticsearch features', async () => { + const plugin = new Plugin(initContext); + const { registerElasticsearchFeature } = await plugin.setup(coreSetup, {}); + registerElasticsearchFeature({ + id: 'baz', + privileges: [ + { + requiredClusterPrivileges: ['all'], + ui: ['baz-ui'], + }, + ], + }); + + const { getElasticsearchFeatures } = plugin.start(coreStart); + + expect(getElasticsearchFeatures().map((f) => f.id)).toMatchInlineSnapshot(` + Array [ + "baz", + ] + `); + }); + it('registers a capabilities provider', async () => { const plugin = new Plugin(initContext); await plugin.setup(coreSetup, {}); diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index 61b66d95ca44f..8a799887bba09 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -15,27 +15,40 @@ import { Capabilities as UICapabilities } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/server'; import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/vis_type_timelion/server'; import { FeatureRegistry } from './feature_registry'; -import { Feature, FeatureConfig } from '../common/feature'; import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; import { buildOSSFeatures } from './oss_features'; import { defineRoutes } from './routes'; +import { + ElasticsearchFeatureConfig, + ElasticsearchFeature, + KibanaFeature, + KibanaFeatureConfig, +} from '../common'; /** * Describes public Features plugin contract returned at the `setup` stage. */ export interface PluginSetupContract { - registerFeature(feature: FeatureConfig): void; + registerKibanaFeature(feature: KibanaFeatureConfig): void; + registerElasticsearchFeature(feature: ElasticsearchFeatureConfig): void; + /* + * Calling this function during setup will crash Kibana. + * Use start contract instead. + * @deprecated + * */ + getKibanaFeatures(): KibanaFeature[]; /* * Calling this function during setup will crash Kibana. * Use start contract instead. * @deprecated * */ - getFeatures(): Feature[]; + getElasticsearchFeatures(): ElasticsearchFeature[]; getFeaturesUICapabilities(): UICapabilities; } export interface PluginStartContract { - getFeatures(): Feature[]; + getElasticsearchFeatures(): ElasticsearchFeature[]; + getKibanaFeatures(): KibanaFeature[]; } /** @@ -62,13 +75,22 @@ export class Plugin { }); const getFeaturesUICapabilities = () => - uiCapabilitiesForFeatures(this.featureRegistry.getAll()); + uiCapabilitiesForFeatures( + this.featureRegistry.getAllKibanaFeatures(), + this.featureRegistry.getAllElasticsearchFeatures() + ); core.capabilities.registerProvider(getFeaturesUICapabilities); return deepFreeze({ - registerFeature: this.featureRegistry.register.bind(this.featureRegistry), - getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry), + registerKibanaFeature: this.featureRegistry.registerKibanaFeature.bind(this.featureRegistry), + registerElasticsearchFeature: this.featureRegistry.registerElasticsearchFeature.bind( + this.featureRegistry + ), + getKibanaFeatures: this.featureRegistry.getAllKibanaFeatures.bind(this.featureRegistry), + getElasticsearchFeatures: this.featureRegistry.getAllElasticsearchFeatures.bind( + this.featureRegistry + ), getFeaturesUICapabilities, }); } @@ -77,7 +99,10 @@ export class Plugin { this.registerOssFeatures(core.savedObjects); return deepFreeze({ - getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry), + getElasticsearchFeatures: this.featureRegistry.getAllElasticsearchFeatures.bind( + this.featureRegistry + ), + getKibanaFeatures: this.featureRegistry.getAllKibanaFeatures.bind(this.featureRegistry), }); } @@ -98,7 +123,7 @@ export class Plugin { }); for (const feature of features) { - this.featureRegistry.register(feature); + this.featureRegistry.registerKibanaFeature(feature); } } } diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index 3d1efc8a479b2..30aa6d07f6b5a 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -11,7 +11,7 @@ import { httpServerMock, httpServiceMock, coreMock } from '../../../../../src/co import { LicenseType } from '../../../licensing/server/'; import { licensingMock } from '../../../licensing/server/mocks'; import { RequestHandler } from '../../../../../src/core/server'; -import { FeatureConfig } from '../../common'; +import { KibanaFeatureConfig } from '../../common'; function createContextMock(licenseType: LicenseType = 'gold') { return { @@ -24,14 +24,14 @@ describe('GET /api/features', () => { let routeHandler: RequestHandler; beforeEach(() => { const featureRegistry = new FeatureRegistry(); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_1', name: 'Feature 1', app: [], privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_2', name: 'Feature 2', order: 2, @@ -39,7 +39,7 @@ describe('GET /api/features', () => { privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_3', name: 'Feature 2', order: 1, @@ -47,7 +47,7 @@ describe('GET /api/features', () => { privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'licensed_feature', name: 'Licensed Feature', app: ['bar-app'], @@ -70,7 +70,7 @@ describe('GET /api/features', () => { expect(mockResponse.ok).toHaveBeenCalledTimes(1); const [call] = mockResponse.ok.mock.calls; - const body = call[0]!.body as FeatureConfig[]; + const body = call[0]!.body as KibanaFeatureConfig[]; const features = body.map((feature) => ({ id: feature.id, order: feature.order })); expect(features).toEqual([ @@ -99,7 +99,7 @@ describe('GET /api/features', () => { expect(mockResponse.ok).toHaveBeenCalledTimes(1); const [call] = mockResponse.ok.mock.calls; - const body = call[0]!.body as FeatureConfig[]; + const body = call[0]!.body as KibanaFeatureConfig[]; const features = body.map((feature) => ({ id: feature.id, order: feature.order })); @@ -129,7 +129,7 @@ describe('GET /api/features', () => { expect(mockResponse.ok).toHaveBeenCalledTimes(1); const [call] = mockResponse.ok.mock.calls; - const body = call[0]!.body as FeatureConfig[]; + const body = call[0]!.body as KibanaFeatureConfig[]; const features = body.map((feature) => ({ id: feature.id, order: feature.order })); @@ -159,7 +159,7 @@ describe('GET /api/features', () => { expect(mockResponse.ok).toHaveBeenCalledTimes(1); const [call] = mockResponse.ok.mock.calls; - const body = call[0]!.body as FeatureConfig[]; + const body = call[0]!.body as KibanaFeatureConfig[]; const features = body.map((feature) => ({ id: feature.id, order: feature.order })); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index 147d34d124fca..b5a4203d7a768 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -26,7 +26,7 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) }, }, (context, request, response) => { - const allFeatures = featureRegistry.getAll(); + const allFeatures = featureRegistry.getAllKibanaFeatures(); return response.ok({ body: allFeatures diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index 35dcc4cf42b37..7532bc0573b08 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -5,10 +5,10 @@ */ import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; -import { Feature } from '.'; -import { SubFeaturePrivilegeGroupConfig } from '../common'; +import { KibanaFeature } from '.'; +import { SubFeaturePrivilegeGroupConfig, ElasticsearchFeature } from '../common'; -function createFeaturePrivilege(capabilities: string[] = []) { +function createKibanaFeaturePrivilege(capabilities: string[] = []) { return { savedObject: { all: [], @@ -19,7 +19,7 @@ function createFeaturePrivilege(capabilities: string[] = []) { }; } -function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) { +function createKibanaSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) { return { id: privilegeId, name: `sub-feature privilege ${privilegeId}`, @@ -35,44 +35,101 @@ function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = describe('populateUICapabilities', () => { it('handles no original uiCapabilities and no registered features gracefully', () => { - expect(uiCapabilitiesForFeatures([])).toEqual({}); + expect(uiCapabilitiesForFeatures([], [])).toEqual({}); }); - it('handles features with no registered capabilities', () => { + it('handles kibana features with no registered capabilities', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(), - read: createFeaturePrivilege(), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(), + read: createKibanaFeaturePrivilege(), + }, + }), + ], + [] + ) + ).toEqual({ + catalogue: {}, + management: {}, + newFeature: {}, + }); + }); + + it('handles elasticsearch features with no registered capabilities', () => { + expect( + uiCapabilitiesForFeatures( + [], + [ + new ElasticsearchFeature({ + id: 'newFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: [], + }, + ], + }), + ] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: {}, }); }); - it('augments the original uiCapabilities with registered feature capabilities', () => { + it('augments the original uiCapabilities with registered kibana feature capabilities', () => { + expect( + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(), + }, + }), + ], + [] + ) + ).toEqual({ + catalogue: {}, + management: {}, + newFeature: { + capability1: true, + capability2: true, + }, + }); + }); + + it('augments the original uiCapabilities with registered elasticsearch feature capabilities', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(), - }, - }), - ]) + uiCapabilitiesForFeatures( + [], + [ + new ElasticsearchFeature({ + id: 'newFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['capability1', 'capability2'], + }, + ], + }), + ] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -80,26 +137,66 @@ describe('populateUICapabilities', () => { }); }); - it('combines catalogue entries from multiple features', () => { + it('combines catalogue entries from multiple kibana features', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - catalogue: ['anotherFooEntry', 'anotherBarEntry'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + catalogue: ['anotherFooEntry', 'anotherBarEntry'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['capability3', 'capability4']), + }, + }), + ], + [] + ) ).toEqual({ catalogue: { anotherFooEntry: true, anotherBarEntry: true, }, + management: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + }, + }); + }); + + it('combines catalogue entries from multiple elasticsearch privileges', () => { + expect( + uiCapabilitiesForFeatures( + [], + [ + new ElasticsearchFeature({ + id: 'newFeature', + catalogue: ['anotherFooEntry', 'anotherBarEntry'], + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['capability1', 'capability2'], + }, + { + requiredClusterPrivileges: [], + ui: ['capability3', 'capability4'], + }, + ], + }), + ] + ) + ).toEqual({ + catalogue: { + anotherFooEntry: true, + anotherBarEntry: true, + }, + management: {}, newFeature: { capability1: true, capability2: true, @@ -111,20 +208,24 @@ describe('populateUICapabilities', () => { it(`merges capabilities from all feature privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -137,30 +238,38 @@ describe('populateUICapabilities', () => { it(`supports capabilities from reserved privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: null, - reserved: { - description: '', - privileges: [ - { - id: 'rp_1', - privilege: createFeaturePrivilege(['capability1', 'capability2']), - }, - { - id: 'rp_2', - privilege: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), - }, - ], - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: null, + reserved: { + description: '', + privileges: [ + { + id: 'rp_1', + privilege: createKibanaFeaturePrivilege(['capability1', 'capability2']), + }, + { + id: 'rp_2', + privilege: createKibanaFeaturePrivilege([ + 'capability3', + 'capability4', + 'capability5', + ]), + }, + ], + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -173,53 +282,60 @@ describe('populateUICapabilities', () => { it(`supports merging features with sub privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - subFeatures: [ - { - name: 'sub-feature-1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-1', ['capability5']), - createSubFeaturePrivilege('privilege-2', ['capability6']), - ], - } as SubFeaturePrivilegeGroupConfig, - { - groupType: 'mutually_exclusive', - privileges: [ - createSubFeaturePrivilege('privilege-3', ['capability7']), - createSubFeaturePrivilege('privilege-4', ['capability8']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['capability3', 'capability4']), }, - { - name: 'sub-feature-2', - privilegeGroups: [ - { - name: 'Group Name', - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], - }, - ], - }), - ]) + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createKibanaSubFeaturePrivilege('privilege-1', ['capability5']), + createKibanaSubFeaturePrivilege('privilege-2', ['capability6']), + ], + } as SubFeaturePrivilegeGroupConfig, + { + groupType: 'mutually_exclusive', + privileges: [ + createKibanaSubFeaturePrivilege('privilege-3', ['capability7']), + createKibanaSubFeaturePrivilege('privilege-4', ['capability8']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + name: 'Group Name', + groupType: 'independent', + privileges: [ + createKibanaSubFeaturePrivilege('privilege-5', [ + 'capability9', + 'capability10', + ]), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -235,53 +351,132 @@ describe('populateUICapabilities', () => { }); }); - it('supports merging multiple features with multiple privileges each', () => { + it('supports merging multiple kibana features with multiple privileges each', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - new Feature({ - id: 'anotherNewFeature', - name: 'another new feature', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - new Feature({ - id: 'yetAnotherNewFeature', - name: 'yet another new feature', - navLinkId: 'yetAnotherNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['something1', 'something2', 'something3']), - }, - subFeatures: [ - { - name: 'sub-feature-1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-1', ['capability3']), - createSubFeaturePrivilege('privilege-2', ['capability4']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], + uiCapabilitiesForFeatures( + [ + new KibanaFeature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['capability3', 'capability4']), + }, + }), + new KibanaFeature({ + id: 'anotherNewFeature', + name: 'another new feature', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['capability3', 'capability4']), + }, + }), + new KibanaFeature({ + id: 'yetAnotherNewFeature', + name: 'yet another new feature', + navLinkId: 'yetAnotherNavLink', + app: ['bar-app'], + privileges: { + all: createKibanaFeaturePrivilege(['capability1', 'capability2']), + read: createKibanaFeaturePrivilege(['something1', 'something2', 'something3']), }, - ], - }), - ]) + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createKibanaSubFeaturePrivilege('privilege-1', ['capability3']), + createKibanaSubFeaturePrivilege('privilege-2', ['capability4']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), + ], + [] + ) + ).toEqual({ + anotherNewFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + }, + catalogue: {}, + management: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + }, + yetAnotherNewFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + something1: true, + something2: true, + something3: true, + }, + }); + }); + + it('supports merging multiple elasticsearch features with multiple privileges each', () => { + expect( + uiCapabilitiesForFeatures( + [], + [ + new ElasticsearchFeature({ + id: 'newFeature', + + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['capability1', 'capability2'], + }, + { + requiredClusterPrivileges: [], + ui: ['capability3', 'capability4'], + }, + ], + }), + new ElasticsearchFeature({ + id: 'anotherNewFeature', + + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['capability1', 'capability2'], + }, + { + requiredClusterPrivileges: [], + ui: ['capability3', 'capability4'], + }, + ], + }), + new ElasticsearchFeature({ + id: 'yetAnotherNewFeature', + + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['capability1', 'capability2', 'capability3', 'capability4'], + }, + { + requiredClusterPrivileges: [], + ui: ['something1', 'something2', 'something3'], + }, + ], + }), + ] + ) ).toEqual({ anotherNewFeature: { capability1: true, @@ -290,6 +485,7 @@ describe('populateUICapabilities', () => { capability4: true, }, catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.ts index 2570d4540b6a6..d582dbfdab50c 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -5,22 +5,35 @@ */ import _ from 'lodash'; +import { RecursiveReadonly } from '@kbn/utility-types'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { Feature } from '../common/feature'; +import { ElasticsearchFeature, KibanaFeature } from '../common'; const ELIGIBLE_FLAT_MERGE_KEYS = ['catalogue'] as const; +const ELIGIBLE_DEEP_MERGE_KEYS = ['management'] as const; interface FeatureCapabilities { [featureId: string]: Record; } -export function uiCapabilitiesForFeatures(features: Feature[]): UICapabilities { - const featureCapabilities: FeatureCapabilities[] = features.map(getCapabilitiesFromFeature); +export function uiCapabilitiesForFeatures( + kibanaFeatures: KibanaFeature[], + elasticsearchFeatures: ElasticsearchFeature[] +): UICapabilities { + const kibanaFeatureCapabilities = kibanaFeatures.map(getCapabilitiesFromFeature); + const elasticsearchFeatureCapabilities = elasticsearchFeatures.map(getCapabilitiesFromFeature); - return buildCapabilities(...featureCapabilities); + return buildCapabilities(...kibanaFeatureCapabilities, ...elasticsearchFeatureCapabilities); } -function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { +function getCapabilitiesFromFeature( + feature: + | Pick< + KibanaFeature, + 'id' | 'catalogue' | 'management' | 'privileges' | 'subFeatures' | 'reserved' + > + | Pick +): FeatureCapabilities { const UIFeatureCapabilities: FeatureCapabilities = { catalogue: {}, [feature.id]: {}, @@ -39,14 +52,34 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { }; } - const featurePrivileges = Object.values(feature.privileges ?? {}); - if (feature.subFeatures) { - featurePrivileges.push( - ...feature.subFeatures.map((sf) => sf.privilegeGroups.map((pg) => pg.privileges)).flat(2) - ); + if (feature.management) { + const sectionEntries = Object.entries(feature.management); + UIFeatureCapabilities.management = sectionEntries.reduce((acc, [sectionId, sectionItems]) => { + return { + ...acc, + [sectionId]: sectionItems.reduce((acc2, item) => { + return { + ...acc2, + [item]: true, + }; + }, {}), + }; + }, {}); } - if (feature.reserved?.privileges) { - featurePrivileges.push(...feature.reserved.privileges.map((rp) => rp.privilege)); + + const featurePrivileges = Object.values(feature.privileges ?? {}) as Writable< + Array<{ ui: RecursiveReadonly }> + >; + + if (isKibanaFeature(feature)) { + if (feature.subFeatures) { + featurePrivileges.push( + ...feature.subFeatures.map((sf) => sf.privilegeGroups.map((pg) => pg.privileges)).flat(2) + ); + } + if (feature.reserved?.privileges) { + featurePrivileges.push(...feature.reserved.privileges.map((rp) => rp.privilege)); + } } featurePrivileges.forEach((privilege) => { @@ -65,6 +98,20 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { return UIFeatureCapabilities; } +function isKibanaFeature( + feature: Partial | Partial +): feature is KibanaFeature { + // Elasticsearch features define privileges as an array, + // whereas Kibana features define privileges as an object, + // or they define reserved privileges, or they don't define either. + // Elasticsearch features are required to defined privileges. + return ( + (feature as any).reserved != null || + (feature.privileges && !Array.isArray(feature.privileges)) || + feature.privileges === null + ); +} + function buildCapabilities(...allFeatureCapabilities: FeatureCapabilities[]): UICapabilities { return allFeatureCapabilities.reduce((acc, capabilities) => { const mergableCapabilities = _.omit(capabilities, ...ELIGIBLE_FLAT_MERGE_KEYS); @@ -81,6 +128,14 @@ function buildCapabilities(...allFeatureCapabilities: FeatureCapabilities[]): UI }; }); + ELIGIBLE_DEEP_MERGE_KEYS.forEach((key) => { + mergedFeatureCapabilities[key] = _.merge( + {}, + mergedFeatureCapabilities[key], + capabilities[key] + ); + }); + return mergedFeatureCapabilities; }, {} as UICapabilities); } diff --git a/x-pack/plugins/graph/server/plugin.ts b/x-pack/plugins/graph/server/plugin.ts index b2b825fa4683b..d69c592655fb5 100644 --- a/x-pack/plugins/graph/server/plugin.ts +++ b/x-pack/plugins/graph/server/plugin.ts @@ -41,7 +41,7 @@ export class GraphPlugin implements Plugin { } if (features) { - features.registerFeature({ + features.registerKibanaFeature({ id: 'graph', name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index f899287642786..479d651fc6698 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -5,7 +5,8 @@ "ui": true, "requiredPlugins": [ "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts index 76d8539eb4a07..3075f9c89eb8d 100644 --- a/x-pack/plugins/index_lifecycle_management/server/plugin.ts +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -60,7 +60,10 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { + async setup( + { http }: CoreSetup, + { licensing, indexManagement, features }: Dependencies + ): Promise { const router = http.createRouter(); const config = await this.config$.pipe(first()).toPromise(); @@ -78,6 +81,19 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { this.dataManagementESClient = this.dataManagementESClient ?? (await getCustomEsClient(getStartServices)); diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index fce0414dee936..7aa91629f0a47 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { LegacyScopedClusterClient, IRouter } from 'src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { License, IndexDataEnricher } from './services'; @@ -12,6 +13,7 @@ import { isEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 90b73b9a7585a..737f7ed1b6e4f 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -132,8 +132,8 @@ export class InfraServerPlugin { ...domainLibs, }; - plugins.features.registerFeature(METRICS_FEATURE); - plugins.features.registerFeature(LOGS_FEATURE); + plugins.features.registerKibanaFeature(METRICS_FEATURE); + plugins.features.registerKibanaFeature(LOGS_FEATURE); plugins.home.sampleData.addAppLinksToSampleDataset('logs', [ { diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 4a7677d69d6e7..b10f3527a0459 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -173,7 +173,7 @@ export class IngestManagerPlugin // Register feature // TODO: Flesh out privileges if (deps.features) { - deps.features.registerFeature({ + deps.features.registerKibanaFeature({ id: PLUGIN_ID, name: 'Ingest Manager', icon: 'savedObjectsApp', diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json index 75e5e9b5d6c51..38d28fbba20b4 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.json +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "server": true, "ui": true, - "requiredPlugins": ["licensing", "management"], + "requiredPlugins": ["licensing", "management", "features"], "optionalPlugins": ["security", "usageCollection"], "configPath": ["xpack", "ingest_pipelines"], "requiredBundles": ["esUiShared", "kibanaReact"] diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts index 7a78bf608b8e1..12668e7c4eadb 100644 --- a/x-pack/plugins/ingest_pipelines/server/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -25,7 +25,7 @@ export class IngestPipelinesPlugin implements Plugin { this.apiRoutes = new ApiRoutes(); } - public setup({ http }: CoreSetup, { licensing, security }: Dependencies) { + public setup({ http }: CoreSetup, { licensing, security, features }: Dependencies) { this.logger.debug('ingest_pipelines: setup'); const router = http.createRouter(); @@ -44,6 +44,19 @@ export class IngestPipelinesPlugin implements Plugin { } ); + features.registerElasticsearchFeature({ + id: 'ingest_pipelines', + management: { + ingest: ['ingest_pipelines'], + }, + privileges: [ + { + ui: [], + requiredClusterPrivileges: ['manage_pipeline', 'cluster:monitor/nodes/info'], + }, + ], + }); + this.apiRoutes.setup({ router, license: this.license, diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts index 261317daa26d9..c5d9158caa569 100644 --- a/x-pack/plugins/ingest_pipelines/server/types.ts +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -7,11 +7,13 @@ import { IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; import { isEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; + features: FeaturesPluginSetup; licensing: LicensingPluginSetup; } diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 3dbf99fced0b0..1f925a453898e 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["home", "licensing", "management"], + "requiredPlugins": ["home", "licensing", "management", "features"], "optionalPlugins": ["telemetry"], "configPath": ["xpack", "license_management"], "extraPublicDirs": ["common/constants"], diff --git a/x-pack/plugins/license_management/server/plugin.ts b/x-pack/plugins/license_management/server/plugin.ts index 7b1887e438024..cb973fd9154ac 100644 --- a/x-pack/plugins/license_management/server/plugin.ts +++ b/x-pack/plugins/license_management/server/plugin.ts @@ -13,9 +13,22 @@ import { Dependencies } from './types'; export class LicenseManagementServerPlugin implements Plugin { private readonly apiRoutes = new ApiRoutes(); - setup({ http }: CoreSetup, { licensing, security }: Dependencies) { + setup({ http }: CoreSetup, { licensing, features, security }: Dependencies) { const router = http.createRouter(); + features.registerElasticsearchFeature({ + id: 'license_management', + management: { + stack: ['license_management'], + }, + privileges: [ + { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + ], + }); + this.apiRoutes.setup({ router, plugins: { diff --git a/x-pack/plugins/license_management/server/types.ts b/x-pack/plugins/license_management/server/types.ts index 5b432b7ff0579..911e47b5130fc 100644 --- a/x-pack/plugins/license_management/server/types.ts +++ b/x-pack/plugins/license_management/server/types.ts @@ -5,12 +5,14 @@ */ import { LegacyScopedClusterClient, IRouter } from 'kibana/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { isEsError } from './shared_imports'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; security?: SecurityPluginSetup; } diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json index 5949d5db041f2..0d14312a154e0 100644 --- a/x-pack/plugins/logstash/kibana.json +++ b/x-pack/plugins/logstash/kibana.json @@ -5,7 +5,8 @@ "configPath": ["xpack", "logstash"], "requiredPlugins": [ "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "home", diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index eb79e1d2a8d8b..0347a606a8044 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -12,6 +12,7 @@ import { PluginInitializerContext, } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { registerRoutes } from './routes'; @@ -19,6 +20,7 @@ import { registerRoutes } from './routes'; interface SetupDeps { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } export class LogstashPlugin implements Plugin { @@ -34,6 +36,22 @@ export class LogstashPlugin implements Plugin { this.coreSetup = core; registerRoutes(core.http.createRouter(), deps.security); + + deps.features.registerElasticsearchFeature({ + id: 'pipelines', + management: { + ingest: ['pipelines'], + }, + privileges: [ + { + requiredClusterPrivileges: [], + requiredIndexPrivileges: { + ['.logstash']: ['read'], + }, + ui: [], + }, + ], + }); } start(core: CoreStart) { diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 6862e7536b07f..5eb0482905e36 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -163,7 +163,7 @@ export class MapsPlugin implements Plugin { this._initHomeData(home, core.http.basePath.prepend, mapsLegacyConfig); - features.registerFeature({ + features.registerKibanaFeature({ id: APP_ID, name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index 8f29365fdca1b..42f056b890828 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -102,6 +102,7 @@ export function getPluginPrivileges() { ...privilege, api: userMlCapabilitiesKeys.map((k) => `ml:${k}`), catalogue: [PLUGIN_ID], + management: { insightsAndAlerting: [] }, ui: userMlCapabilitiesKeys, savedObject: { all: [], diff --git a/x-pack/plugins/ml/public/application/management/index.ts b/x-pack/plugins/ml/public/application/management/index.ts index a1b8484f200ec..72073dfd26a97 100644 --- a/x-pack/plugins/ml/public/application/management/index.ts +++ b/x-pack/plugins/ml/public/application/management/index.ts @@ -23,7 +23,7 @@ export function registerManagementSection( core: CoreSetup ) { if (management !== undefined) { - management.sections.section.insightsAndAlerting.registerApp({ + return management.sections.section.insightsAndAlerting.registerApp({ id: 'jobsListLink', title: i18n.translate('xpack.ml.management.jobsListTitle', { defaultMessage: 'Machine Learning Jobs', diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 3e8ab99e341ad..fc0d21e9353cf 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -101,6 +101,8 @@ export class MlPlugin implements Plugin { }, }); + const managementApp = registerManagementSection(pluginsSetup.management, core); + const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); @@ -110,26 +112,35 @@ export class MlPlugin implements Plugin { registerFeature(pluginsSetup.home); } + const { capabilities } = coreStart.application; + // register ML for the index pattern management no data screen. pluginsSetup.indexPatternManagement.environment.update({ ml: () => - coreStart.application.capabilities.ml.canFindFileStructure - ? MlCardState.ENABLED - : MlCardState.HIDDEN, + capabilities.ml.canFindFileStructure ? MlCardState.ENABLED : MlCardState.HIDDEN, }); + const canManageMLJobs = capabilities.management?.insightsAndAlerting?.jobsListLink ?? false; + // register various ML plugin features which require a full license if (isFullLicense(license)) { - registerManagementSection(pluginsSetup.management, core); + if (canManageMLJobs && managementApp) { + managementApp.enable(); + } registerEmbeddables(pluginsSetup.embeddable, core); registerMlUiActions(pluginsSetup.uiActions, core); registerUrlGenerator(pluginsSetup.share, core); + } else if (managementApp) { + managementApp.disable(); } } else { // if ml is disabled in elasticsearch, disable ML in kibana this.appUpdater.next(() => ({ status: AppStatus.inaccessible, })); + if (managementApp) { + managementApp.disable(); + } } }); diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 39672f5b188bc..cf248fcc60896 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -67,7 +67,7 @@ export class MlServerPlugin implements Plugin ({ + requiredClusterPrivileges: [], + requiredRoles: [role], + ui: [], + })), + }); + } + /* * Gives synchronous access to the config */ diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index e0d018869cef1..d323a281c06ff 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -17,6 +17,7 @@ jest.mock('./browsers/install', () => ({ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin } from './plugin'; import { createMockConfigSchema } from './test_helpers'; +import { featuresPluginMock } from '../../features/server/mocks'; const sleep = (time: number) => new Promise((r) => setTimeout(r, time)); @@ -35,6 +36,7 @@ describe('Reporting Plugin', () => { coreStart = await coreMock.createStart(); pluginSetup = ({ licensing: {}, + features: featuresPluginMock.createSetup(), usageCollection: { makeUsageCollector: jest.fn(), registerCollector: jest.fn(), diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index af1ccfd592b96..adb89abe20280 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -70,13 +70,14 @@ export class ReportingPlugin }); const { elasticsearch, http } = core; - const { licensing, security } = plugins; + const { features, licensing, security } = plugins; const { initializerContext: initContext, reportingCore } = this; const router = http.createRouter(); const basePath = http.basePath.get; reportingCore.pluginSetup({ + features, elasticsearch, licensing, basePath, @@ -91,6 +92,8 @@ export class ReportingPlugin (async () => { const config = await buildConfig(initContext, core, this.logger); reportingCore.setConfig(config); + // Feature registration relies on config, so it cannot be setup before here. + reportingCore.registerFeature(); this.logger.debug('Setup complete'); })().catch((e) => { this.logger.error(`Error in Reporting setup, reporting may not function properly`); diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index d1ebb4d59e631..559726e0b8a99 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -10,6 +10,7 @@ jest.mock('../browsers'); jest.mock('../lib/create_queue'); import * as Rx from 'rxjs'; +import { featuresPluginMock } from '../../../features/server/mocks'; import { ReportingConfig, ReportingCore } from '../'; import { chromium, @@ -32,6 +33,7 @@ const createMockPluginSetup = ( setupMock?: any ): ReportingInternalSetup => { return { + features: featuresPluginMock.createSetup(), elasticsearch: setupMock.elasticsearch || { legacy: { client: {} } }, basePath: setupMock.basePath || '/all-about-that-basepath', router: setupMock.router, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index bb2d5368cd181..c67a95c2de754 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -9,6 +9,7 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { DataPluginStart } from 'src/plugins/data/server/plugin'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { CancellationToken } from '../../../plugins/reporting/common'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../security/server'; import { JobStatus } from '../common/types'; @@ -92,6 +93,7 @@ export interface ConditionalHeaders { export interface ReportingSetupDeps { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; security?: SecurityPluginSetup; usageCollection?: UsageCollectionSetup; } diff --git a/x-pack/plugins/rollup/kibana.json b/x-pack/plugins/rollup/kibana.json index e6915f65599cc..725b563c3674f 100644 --- a/x-pack/plugins/rollup/kibana.json +++ b/x-pack/plugins/rollup/kibana.json @@ -7,7 +7,8 @@ "requiredPlugins": [ "indexPatternManagement", "management", - "licensing" + "licensing", + "features" ], "optionalPlugins": [ "home", diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 713852b4d7398..8b3a6355f950d 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -64,7 +64,7 @@ export class RollupPlugin implements Plugin { public setup( { http, uiSettings, getStartServices }: CoreSetup, - { licensing, indexManagement, visTypeTimeseries, usageCollection }: Dependencies + { features, licensing, indexManagement, visTypeTimeseries, usageCollection }: Dependencies ) { this.license.setup( { @@ -80,6 +80,20 @@ export class RollupPlugin implements Plugin { } ); + features.registerElasticsearchFeature({ + id: 'rollup_jobs', + management: { + data: ['rollup_jobs'], + }, + catalogue: ['rollup_jobs'], + privileges: [ + { + requiredClusterPrivileges: ['manage_rollup'], + ui: [], + }, + ], + }); + http.registerRouteHandlerContext('rollup', async (context, request) => { this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts index 2a7644de764b2..290d2df050099 100644 --- a/x-pack/plugins/rollup/server/types.ts +++ b/x-pack/plugins/rollup/server/types.ts @@ -9,6 +9,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { License } from './services'; import { IndexPatternsFetcher } from './shared_imports'; @@ -22,6 +23,7 @@ export interface Dependencies { visTypeTimeseries?: VisTypeTimeseriesSetup; usageCollection?: UsageCollectionSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/security/public/management/management_service.test.ts b/x-pack/plugins/security/public/management/management_service.test.ts index ce93fb7c98f41..cd06693a43bf9 100644 --- a/x-pack/plugins/security/public/management/management_service.test.ts +++ b/x-pack/plugins/security/public/management/management_service.test.ts @@ -78,7 +78,10 @@ describe('ManagementService', () => { }); describe('start()', () => { - function startService(initialFeatures: Partial) { + function startService( + initialFeatures: Partial, + canManageSecurity: boolean = true + ) { const { fatalErrors, getStartServices } = coreMock.createSetup(); const licenseSubject = new BehaviorSubject( @@ -106,10 +109,11 @@ describe('ManagementService', () => { management: managementSetup, }); - const getMockedApp = () => { + const getMockedApp = (id: string) => { // All apps are enabled by default. let enabled = true; return ({ + id, get enabled() { return enabled; }, @@ -123,13 +127,26 @@ describe('ManagementService', () => { }; mockSection.getApp = jest.fn().mockImplementation((id) => mockApps.get(id)); const mockApps = new Map>([ - [usersManagementApp.id, getMockedApp()], - [rolesManagementApp.id, getMockedApp()], - [apiKeysManagementApp.id, getMockedApp()], - [roleMappingsManagementApp.id, getMockedApp()], + [usersManagementApp.id, getMockedApp(usersManagementApp.id)], + [rolesManagementApp.id, getMockedApp(rolesManagementApp.id)], + [apiKeysManagementApp.id, getMockedApp(apiKeysManagementApp.id)], + [roleMappingsManagementApp.id, getMockedApp(roleMappingsManagementApp.id)], ] as Array<[string, jest.Mocked]>); - service.start(); + service.start({ + capabilities: { + management: { + security: { + users: canManageSecurity, + roles: canManageSecurity, + role_mappings: canManageSecurity, + api_keys: canManageSecurity, + }, + }, + navLinks: {}, + catalogue: {}, + }, + }); return { mockApps, @@ -178,6 +195,19 @@ describe('ManagementService', () => { } }); + it('apps are disabled if capabilities are false', () => { + const { mockApps } = startService( + { + showLinks: true, + showRoleMappingsManagement: true, + }, + false + ); + for (const [, mockApp] of mockApps) { + expect(mockApp.enabled).toBe(false); + } + }); + it('role mappings app is disabled if `showRoleMappingsManagement` changes after `start`', () => { const { mockApps, updateFeatures } = startService({ showLinks: true, diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 199fd917da071..1fc648c12f80d 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -5,7 +5,7 @@ */ import { Subscription } from 'rxjs'; -import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup, Capabilities } from 'src/core/public'; import { ManagementApp, ManagementSetup, @@ -27,6 +27,10 @@ interface SetupParams { getStartServices: StartServicesAccessor; } +interface StartParams { + capabilities: Capabilities; +} + export class ManagementService { private license!: SecurityLicense; private licenseFeaturesSubscription?: Subscription; @@ -44,7 +48,7 @@ export class ManagementService { this.securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); } - start() { + start({ capabilities }: StartParams) { this.licenseFeaturesSubscription = this.license.features$.subscribe(async (features) => { const securitySection = this.securitySection!; @@ -61,6 +65,11 @@ export class ManagementService { // Iterate over all registered apps and update their enable status depending on the available // license features. for (const [app, enableStatus] of securityManagementAppsStatuses) { + if (capabilities.management.security[app.id] !== true) { + app.disable(); + continue; + } + if (app.enabled === enableStatus) { continue; } diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts index 08561234fd706..2b78355787ff2 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureConfig } from '../../../../../features/public'; +import { KibanaFeature, KibanaFeatureConfig } from '../../../../../features/public'; export const createFeature = ( - config: Pick & { + config: Pick< + KibanaFeatureConfig, + 'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip' + > & { excludeFromBaseAll?: boolean; excludeFromBaseRead?: boolean; - privileges?: FeatureConfig['privileges']; + privileges?: KibanaFeatureConfig['privileges']; } ) => { const { excludeFromBaseAll, excludeFromBaseRead, privileges, ...rest } = config; - return new Feature({ + return new KibanaFeature({ icon: 'discoverApp', navLinkId: 'discover', app: [], diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts index 6821c163d817d..02a18039cee74 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts @@ -7,7 +7,7 @@ import { Actions } from '../../../../server/authorization'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { privilegesFactory } from '../../../../server/authorization/privileges'; -import { Feature } from '../../../../../features/public'; +import { KibanaFeature } from '../../../../../features/public'; import { KibanaPrivileges } from '../model'; import { SecurityLicenseFeatures } from '../../..'; @@ -15,11 +15,11 @@ import { SecurityLicenseFeatures } from '../../..'; import { featuresPluginMock } from '../../../../../features/server/mocks'; export const createRawKibanaPrivileges = ( - features: Feature[], + features: KibanaFeature[], { allowSubFeaturePrivileges = true } = {} ) => { const featuresService = featuresPluginMock.createSetup(); - featuresService.getFeatures.mockReturnValue(features); + featuresService.getKibanaFeatures.mockReturnValue(features); const licensingService = { getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures), @@ -33,7 +33,7 @@ export const createRawKibanaPrivileges = ( }; export const createKibanaPrivileges = ( - features: Feature[], + features: KibanaFeature[], { allowSubFeaturePrivileges = true } = {} ) => { return new KibanaPrivileges( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index f6fe2f394fd36..bf791b37087bd 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { Capabilities } from 'src/core/public'; -import { Feature } from '../../../../../features/public'; +import { KibanaFeature } from '../../../../../features/public'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; import { EditRolePage } from './edit_role_page'; @@ -27,7 +27,7 @@ import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; const buildFeatures = () => { return [ - new Feature({ + new KibanaFeature({ id: 'feature1', name: 'Feature 1', icon: 'addDataApp', @@ -51,7 +51,7 @@ const buildFeatures = () => { }, }, }), - new Feature({ + new KibanaFeature({ id: 'feature2', name: 'Feature 2', icon: 'addDataApp', @@ -75,7 +75,7 @@ const buildFeatures = () => { }, }, }), - ] as Feature[]; + ] as KibanaFeature[]; }; const buildBuiltinESPrivileges = () => { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index 15888733ec424..01f8969e61f43 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -40,7 +40,7 @@ import { } from 'src/core/public'; import { ScopedHistory } from 'kibana/public'; import { FeaturesPluginStart } from '../../../../../features/public'; -import { Feature } from '../../../../../features/common'; +import { KibanaFeature } from '../../../../../features/common'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { Space } from '../../../../../spaces/public'; import { @@ -247,7 +247,7 @@ function useFeatures( getFeatures: FeaturesPluginStart['getFeatures'], fatalErrors: FatalErrorsSetup ) { - const [features, setFeatures] = useState(null); + const [features, setFeatures] = useState(null); useEffect(() => { getFeatures() .catch((err: IHttpFetchError) => { @@ -260,7 +260,7 @@ function useFeatures( // 404 here, and respond in a way that still allows the UI to render itself. const unauthorizedForFeatures = err.response?.status === 404; if (unauthorizedForFeatures) { - return [] as Feature[]; + return [] as KibanaFeature[]; } fatalErrors.add(err); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx index 2a0922d614f1d..02d692bf9f507 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { FeatureTable } from './feature_table'; import { Role } from '../../../../../../../common/model'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { Feature, SubFeatureConfig } from '../../../../../../../../features/public'; +import { KibanaFeature, SubFeatureConfig } from '../../../../../../../../features/public'; import { kibanaFeatures, createFeature } from '../../../../__fixtures__/kibana_features'; import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; @@ -24,7 +24,7 @@ const createRole = (kibana: Role['kibana'] = []): Role => { }; interface TestConfig { - features: Feature[]; + features: KibanaFeature[]; role: Role; privilegeIndex: number; calculateDisplayedPrivileges: boolean; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 5530d9964f8cd..bc60613345910 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -13,7 +13,7 @@ import { PrivilegeDisplay } from './privilege_display'; import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; -import { Feature } from '../../../../../../../../features/public'; +import { KibanaFeature } from '../../../../../../../../features/public'; import { findTestSubject } from 'test_utils/find_test_subject'; interface TableRow { @@ -24,7 +24,7 @@ interface TableRow { } const features = [ - new Feature({ + new KibanaFeature({ id: 'normal', name: 'normal feature', app: [], @@ -39,7 +39,7 @@ const features = [ }, }, }), - new Feature({ + new KibanaFeature({ id: 'normal_with_sub', name: 'normal feature with sub features', app: [], @@ -92,7 +92,7 @@ const features = [ }, ], }), - new Feature({ + new KibanaFeature({ id: 'bothPrivilegesExcludedFromBase', name: 'bothPrivilegesExcludedFromBase', app: [], @@ -109,7 +109,7 @@ const features = [ }, }, }), - new Feature({ + new KibanaFeature({ id: 'allPrivilegeExcludedFromBase', name: 'allPrivilegeExcludedFromBase', app: [], diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts index fd93aaa23194a..4739346b2cb76 100644 --- a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -8,7 +8,7 @@ import { RawKibanaPrivileges, RoleKibanaPrivilege } from '../../../../common/mod import { KibanaPrivilege } from './kibana_privilege'; import { PrivilegeCollection } from './privilege_collection'; import { SecuredFeature } from './secured_feature'; -import { Feature } from '../../../../../features/common'; +import { KibanaFeature } from '../../../../../features/common'; import { isGlobalPrivilegeDefinition } from '../edit_role/privilege_utils'; function toBasePrivilege(entry: [string, string[]]): [string, KibanaPrivilege] { @@ -29,7 +29,7 @@ export class KibanaPrivileges { private feature: ReadonlyMap; - constructor(rawKibanaPrivileges: RawKibanaPrivileges, features: Feature[]) { + constructor(rawKibanaPrivileges: RawKibanaPrivileges, features: KibanaFeature[]) { this.global = recordsToBasePrivilegeMap(rawKibanaPrivileges.global); this.spaces = recordsToBasePrivilegeMap(rawKibanaPrivileges.space); this.feature = new Map( diff --git a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts index 284a85583c33c..894e06b6e5856 100644 --- a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts +++ b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureConfig } from '../../../../../features/common'; +import { KibanaFeature, KibanaFeatureConfig } from '../../../../../features/common'; import { PrimaryFeaturePrivilege } from './primary_feature_privilege'; import { SecuredSubFeature } from './secured_sub_feature'; import { SubFeaturePrivilege } from './sub_feature_privilege'; -export class SecuredFeature extends Feature { +export class SecuredFeature extends KibanaFeature { private readonly primaryFeaturePrivileges: PrimaryFeaturePrivilege[]; private readonly minimalPrimaryFeaturePrivileges: PrimaryFeaturePrivilege[]; @@ -18,7 +18,10 @@ export class SecuredFeature extends Feature { private readonly securedSubFeatures: SecuredSubFeature[]; - constructor(config: FeatureConfig, actionMapping: { [privilegeId: string]: string[] } = {}) { + constructor( + config: KibanaFeatureConfig, + actionMapping: { [privilegeId: string]: string[] } = {} + ) { super(config); this.primaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( ([id, privilege]) => new PrimaryFeaturePrivilege(id, privilege, actionMapping[id]) diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 8fe7d2805e18e..fb8034da11731 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -114,7 +114,8 @@ describe('Security Plugin', () => { } ); - plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + const coreStart = coreMock.createStart({ basePath: '/some-base-path' }); + plugin.start(coreStart, { data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, management: managementStartMock, diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index e3905dc2acf45..f5770ae2bc35c 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -141,7 +141,7 @@ export class SecurityPlugin this.sessionTimeout.start(); this.navControlService.start({ core }); if (management) { - this.managementService.start(); + this.managementService.start({ capabilities: core.application.capabilities }); } } diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index 75aa27c3c88c6..d4ec9a0e0db51 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -94,7 +94,9 @@ describe('initAPIAuthorization', () => { expect(mockResponse.notFound).not.toHaveBeenCalled(); expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthz.actions.api.get('foo')], + }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); @@ -129,7 +131,9 @@ describe('initAPIAuthorization', () => { expect(mockResponse.notFound).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthz.actions.api.get('foo')], + }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 0ffd3ba7ba823..9129330ec947a 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -29,7 +29,7 @@ export function initAPIAuthorization( const apiActions = actionTags.map((tag) => actions.api.get(tag.substring(tagPrefix.length))); const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); - const checkPrivilegesResponse = await checkPrivileges(apiActions); + const checkPrivilegesResponse = await checkPrivileges({ kibana: apiActions }); // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index 1dc072ab2e6e9..f40d502a9cd7c 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -18,7 +18,7 @@ import { authorizationMock } from './index.mock'; const createFeaturesSetupContractMock = (): FeaturesSetupContract => { const mock = featuresPluginMock.createSetup(); - mock.getFeatures.mockReturnValue([ + mock.getKibanaFeatures.mockReturnValue([ { id: 'foo', name: 'Foo', app: ['foo'], privileges: {} } as any, ]); return mock; @@ -132,7 +132,7 @@ describe('initAppAuthorization', () => { expect(mockResponse.notFound).not.toHaveBeenCalled(); expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); @@ -172,7 +172,7 @@ describe('initAppAuthorization', () => { expect(mockResponse.notFound).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); - expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index 1036997ca821d..4170fd2cdb38a 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -19,7 +19,7 @@ class ProtectedApplications { if (this.applications == null) { this.applications = new Set( this.featuresService - .getFeatures() + .getKibanaFeatures() .map((feature) => feature.app) .flat() ); @@ -63,7 +63,7 @@ export function initAppAuthorization( const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); const appAction = actions.app.get(appId); - const checkPrivilegesResponse = await checkPrivileges(appAction); + const checkPrivilegesResponse = await checkPrivileges({ kibana: appAction }); logger.debug(`authorizing access to "${appId}"`); // we've actually authorized the request diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index 2fdc2d169e972..c00127f7d1229 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -74,6 +74,7 @@ it(`#setup returns exposed services`, () => { packageVersion: 'some-version', features: mockFeaturesSetup, getSpacesService: mockGetSpacesService, + getCurrentUser: jest.fn(), }); expect(authz.actions.version).toBe('version:some-version'); @@ -133,10 +134,11 @@ describe('#start', () => { getSpacesService: jest .fn() .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }), + getCurrentUser: jest.fn(), }); const featuresStart = featuresPluginMock.createStart(); - featuresStart.getFeatures.mockReturnValue([]); + featuresStart.getKibanaFeatures.mockReturnValue([]); authorizationService.start({ clusterClient: mockClusterClient, @@ -203,10 +205,12 @@ it('#stop unsubscribes from license and ES updates.', async () => { getSpacesService: jest .fn() .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }), + getCurrentUser: jest.fn(), }); const featuresStart = featuresPluginMock.createStart(); - featuresStart.getFeatures.mockReturnValue([]); + featuresStart.getKibanaFeatures.mockReturnValue([]); + authorizationService.start({ clusterClient: mockClusterClient, features: featuresStart, diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.ts index 2dead301b298a..fd3a60fb4d900 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.ts @@ -22,7 +22,7 @@ import { import { SpacesService } from '../plugin'; import { Actions } from './actions'; -import { CheckPrivilegesWithRequest, checkPrivilegesWithRequestFactory } from './check_privileges'; +import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { CheckPrivilegesDynamicallyWithRequest, checkPrivilegesDynamicallyWithRequestFactory, @@ -41,7 +41,9 @@ import { validateReservedPrivileges } from './validate_reserved_privileges'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; +import { CheckPrivilegesWithRequest } from './types'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { AuthenticatedUser } from '..'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; @@ -57,6 +59,7 @@ interface AuthorizationServiceSetupParams { features: FeaturesPluginSetup; kibanaIndexName: string; getSpacesService(): SpacesService | undefined; + getCurrentUser(request: KibanaRequest): AuthenticatedUser | null; } interface AuthorizationServiceStartParams { @@ -92,6 +95,7 @@ export class AuthorizationService { features, kibanaIndexName, getSpacesService, + getCurrentUser, }: AuthorizationServiceSetupParams): AuthorizationServiceSetup { this.logger = loggers.get('authorization'); this.applicationName = `${APPLICATION_PREFIX}${kibanaIndexName}`; @@ -132,9 +136,11 @@ export class AuthorizationService { const disableUICapabilities = disableUICapabilitiesFactory( request, - features.getFeatures(), + features.getKibanaFeatures(), + features.getElasticsearchFeatures(), this.logger, - authz + authz, + getCurrentUser(request) ); if (!request.auth.isAuthenticated) { @@ -152,7 +158,7 @@ export class AuthorizationService { } start({ clusterClient, features, online$ }: AuthorizationServiceStartParams) { - const allFeatures = features.getFeatures(); + const allFeatures = features.getKibanaFeatures(); validateFeaturePrivileges(allFeatures); validateReservedPrivileges(allFeatures); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index b380f45a12d81..4151ff645005d 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -33,7 +33,11 @@ const createMockClusterClient = (response: any) => { describe('#atSpace', () => { const checkPrivilegesAtSpaceTest = async (options: { spaceId: string; - privilegeOrPrivileges: string | string[]; + kibanaPrivileges?: string | string[]; + elasticsearchPrivileges?: { + cluster: string[]; + index: Record; + }; esHasPrivilegesResponse: HasPrivilegesResponse; }) => { const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( @@ -50,25 +54,39 @@ describe('#atSpace', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.atSpace(options.spaceId, options.privilegeOrPrivileges); + actualResult = await checkPrivileges.atSpace(options.spaceId, { + kibana: options.kibanaPrivileges, + elasticsearch: options.elasticsearchPrivileges, + }); } catch (err) { errorThrown = err; } + const expectedIndexPrivilegePayload = Object.entries( + options.elasticsearchPrivileges?.index ?? {} + ).map(([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + })); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + cluster: options.elasticsearchPrivileges?.cluster, + index: expectedIndexPrivilegePayload, applications: [ { application, resources: [`space:${options.spaceId}`], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), + privileges: options.kibanaPrivileges + ? uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.kibanaPrivileges) + ? options.kibanaPrivileges + : [options.kibanaPrivileges]), + ]) + : [mockActions.version, mockActions.login], }, ], }, @@ -83,7 +101,7 @@ describe('#atSpace', () => { test('successful when checking for login and user has login', async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: true, username: 'foo-username', @@ -100,13 +118,19 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -115,7 +139,7 @@ describe('#atSpace', () => { test(`failure when checking for login and user doesn't have login`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -132,13 +156,19 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -147,7 +177,7 @@ describe('#atSpace', () => { test(`throws error when checking for login and user has login but doesn't have version`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -169,7 +199,7 @@ describe('#atSpace', () => { test(`successful when checking for two actions and the user has both`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -191,18 +221,24 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -211,7 +247,7 @@ describe('#atSpace', () => { test(`failure when checking for two actions and the user has only one`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -233,18 +269,24 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -254,7 +296,7 @@ describe('#atSpace', () => { test(`throws a validation error when an extra privilege is present in the response`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -278,7 +320,7 @@ describe('#atSpace', () => { test(`throws a validation error when privileges are missing in the response`, async () => { const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -297,12 +339,551 @@ describe('#atSpace', () => { ); }); }); + + describe('with both Kibana and Elasticsearch privileges', () => { + it('successful when checking for privileges, and user has all', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only es privileges', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only kibana privileges', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has none', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, + "username": "foo-username", + } + `); + }); + }); + + describe('with Elasticsearch privileges', () => { + it('successful when checking for cluster privileges, and user has both', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for index privileges, and user has both', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: [], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for a combination of index and cluster privileges', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for a combination of index and cluster privileges, and some are missing', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: false, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": false, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + }); }); describe('#atSpaces', () => { const checkPrivilegesAtSpacesTest = async (options: { spaceIds: string[]; - privilegeOrPrivileges: string | string[]; + kibanaPrivileges?: string | string[]; + elasticsearchPrivileges?: { + cluster: string[]; + index: Record; + }; esHasPrivilegesResponse: HasPrivilegesResponse; }) => { const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( @@ -319,28 +900,39 @@ describe('#atSpaces', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.atSpaces( - options.spaceIds, - options.privilegeOrPrivileges - ); + actualResult = await checkPrivileges.atSpaces(options.spaceIds, { + kibana: options.kibanaPrivileges, + elasticsearch: options.elasticsearchPrivileges, + }); } catch (err) { errorThrown = err; } + const expectedIndexPrivilegePayload = Object.entries( + options.elasticsearchPrivileges?.index ?? {} + ).map(([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + })); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + cluster: options.elasticsearchPrivileges?.cluster, + index: expectedIndexPrivilegePayload, applications: [ { application, resources: options.spaceIds.map((spaceId) => `space:${spaceId}`), - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), + privileges: options.kibanaPrivileges + ? uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.kibanaPrivileges) + ? options.kibanaPrivileges + : [options.kibanaPrivileges]), + ]) + : [mockActions.version, mockActions.login], }, ], }, @@ -355,7 +947,7 @@ describe('#atSpaces', () => { test('successful when checking for login and user has login at both spaces', async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: true, username: 'foo-username', @@ -376,18 +968,24 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -396,7 +994,7 @@ describe('#atSpaces', () => { test('failure when checking for login and user has login at only one space', async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -417,18 +1015,24 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -437,7 +1041,7 @@ describe('#atSpaces', () => { test(`throws error when checking for login and user has login but doesn't have version`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -463,7 +1067,7 @@ describe('#atSpaces', () => { test(`throws error when Elasticsearch returns malformed response`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -492,7 +1096,7 @@ describe('#atSpaces', () => { test(`successful when checking for two actions at two spaces and user has it all`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -520,28 +1124,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -550,7 +1160,7 @@ describe('#atSpaces', () => { test(`failure when checking for two actions at two spaces and user has one action at one space`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -578,28 +1188,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -608,7 +1224,7 @@ describe('#atSpaces', () => { test(`failure when checking for two actions at two spaces and user has two actions at one space`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -636,28 +1252,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -666,7 +1288,7 @@ describe('#atSpaces', () => { test(`failure when checking for two actions at two spaces and user has two actions at one space & one action at the other`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -694,28 +1316,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -725,7 +1353,7 @@ describe('#atSpaces', () => { test(`throws a validation error when an extra privilege is present in the response`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -755,7 +1383,7 @@ describe('#atSpaces', () => { test(`throws a validation error when privileges are missing in the response`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -783,7 +1411,7 @@ describe('#atSpaces', () => { test(`throws a validation error when an extra space is present in the response`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -816,7 +1444,7 @@ describe('#atSpaces', () => { test(`throws a validation error when an a space is missing in the response`, async () => { const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -836,13 +1464,632 @@ describe('#atSpaces', () => { ); }); }); -}); -describe('#globally', () => { - const checkPrivilegesGloballyTest = async (options: { - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - }) => { + describe('with both Kibana and Elasticsearch privileges', () => { + it('successful when checking for privileges, and user has all', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only es privileges', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only kibana privileges', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has none', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, + "username": "foo-username", + } + `); + }); + }); + + describe('with Elasticsearch privileges', () => { + it('successful when checking for cluster privileges, and user has both', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for index privileges, and user has both', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: [], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for a combination of index and cluster privileges', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for a combination of index and cluster privileges, and some are missing', async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: false, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": false, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + }); +}); + +describe('#globally', () => { + const checkPrivilegesGloballyTest = async (options: { + kibanaPrivileges?: string | string[]; + elasticsearchPrivileges?: { + cluster: string[]; + index: Record; + }; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( options.esHasPrivilegesResponse ); @@ -857,25 +2104,39 @@ describe('#globally', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); + actualResult = await checkPrivileges.globally({ + kibana: options.kibanaPrivileges, + elasticsearch: options.elasticsearchPrivileges, + }); } catch (err) { errorThrown = err; } + const expectedIndexPrivilegePayload = Object.entries( + options.elasticsearchPrivileges?.index ?? {} + ).map(([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + })); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + cluster: options.elasticsearchPrivileges?.cluster, + index: expectedIndexPrivilegePayload, applications: [ { application, resources: [GLOBAL_RESOURCE], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), + privileges: options.kibanaPrivileges + ? uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.kibanaPrivileges) + ? options.kibanaPrivileges + : [options.kibanaPrivileges]), + ]) + : [mockActions.version, mockActions.login], }, ], }, @@ -889,7 +2150,7 @@ describe('#globally', () => { test('successful when checking for login and user has login', async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: true, username: 'foo-username', @@ -906,13 +2167,19 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -920,7 +2187,7 @@ describe('#globally', () => { test(`failure when checking for login and user doesn't have login`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -937,13 +2204,19 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -951,7 +2224,7 @@ describe('#globally', () => { test(`throws error when checking for login and user has login but doesn't have version`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: mockActions.login, + kibanaPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -972,7 +2245,7 @@ describe('#globally', () => { test(`throws error when Elasticsearch returns malformed response`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -996,7 +2269,7 @@ describe('#globally', () => { test(`successful when checking for two actions and the user has both`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -1018,18 +2291,24 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": undefined, - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -1037,7 +2316,7 @@ describe('#globally', () => { test(`failure when checking for two actions and the user has only one`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: [ + kibanaPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, `saved_object:${savedObjectTypes[1]}/get`, ], @@ -1059,18 +2338,24 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": undefined, - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -1079,7 +2364,7 @@ describe('#globally', () => { describe('with a malformed Elasticsearch response', () => { test(`throws a validation error when an extra privilege is present in the response`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -1102,7 +2387,7 @@ describe('#globally', () => { test(`throws a validation error when privileges are missing in the response`, async () => { const result = await checkPrivilegesGloballyTest({ - privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], + kibanaPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, username: 'foo-username', @@ -1121,4 +2406,531 @@ describe('#globally', () => { ); }); }); + + describe('with both Kibana and Elasticsearch privileges', () => { + it('successful when checking for privileges, and user has all', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only es privileges', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has only kibana privileges', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for privileges, and user has none', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + kibanaPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: false, + }, + }, + }, + cluster: { + foo: false, + bar: false, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": false, + "privilege": "foo", + }, + Object { + "authorized": false, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, + "username": "foo-username", + } + `); + }); + }); + + describe('with Elasticsearch privileges', () => { + it('successful when checking for cluster privileges, and user has both', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['foo', 'bar'], + index: {}, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + foo: true, + bar: true, + }, + index: {}, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "foo", + }, + Object { + "authorized": true, + "privilege": "bar", + }, + ], + "index": Object {}, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for index privileges, and user has both', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: [], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('successful when checking for a combination of index and cluster privileges', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: true, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": true, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + + it('failure when checking for a combination of index and cluster privileges, and some are missing', async () => { + const result = await checkPrivilegesGloballyTest({ + elasticsearchPrivileges: { + cluster: ['manage', 'monitor'], + index: { + foo: ['all'], + bar: ['read', 'view_index_metadata'], + }, + }, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, + }, + }, + cluster: { + manage: true, + monitor: true, + }, + index: { + foo: { + all: true, + }, + bar: { + read: true, + view_index_metadata: false, + }, + }, + }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [ + Object { + "authorized": true, + "privilege": "manage", + }, + Object { + "authorized": true, + "privilege": "monitor", + }, + ], + "index": Object { + "bar": Array [ + Object { + "authorized": true, + "privilege": "read", + }, + Object { + "authorized": false, + "privilege": "view_index_metadata", + }, + ], + "foo": Array [ + Object { + "authorized": true, + "privilege": "all", + }, + ], + }, + }, + "kibana": Array [], + }, + "username": "foo-username", + } + `); + }); + }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 3129777a7881f..27e1802b4e5c2 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -8,7 +8,13 @@ import { pick, transform, uniq } from 'lodash'; import { ILegacyClusterClient, KibanaRequest } from '../../../../../src/core/server'; import { GLOBAL_RESOURCE } from '../../common/constants'; import { ResourceSerializer } from './resource_serializer'; -import { HasPrivilegesResponse, HasPrivilegesResponseApplication } from './types'; +import { + HasPrivilegesResponse, + HasPrivilegesResponseApplication, + CheckPrivilegesPayload, + CheckPrivileges, + CheckPrivilegesResponse, +} from './types'; import { validateEsPrivilegeResponse } from './validate_es_response'; interface CheckPrivilegesActions { @@ -16,33 +22,6 @@ interface CheckPrivilegesActions { version: string; } -export interface CheckPrivilegesResponse { - hasAllRequested: boolean; - username: string; - privileges: Array<{ - /** - * If this attribute is undefined, this element is a privilege for the global resource. - */ - resource?: string; - privilege: string; - authorized: boolean; - }>; -} - -export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; - -export interface CheckPrivileges { - atSpace( - spaceId: string, - privilegeOrPrivileges: string | string[] - ): Promise; - atSpaces( - spaceIds: string[], - privilegeOrPrivileges: string | string[] - ): Promise; - globally(privilegeOrPrivileges: string | string[]): Promise; -} - export function checkPrivilegesWithRequestFactory( actions: CheckPrivilegesActions, clusterClient: ILegacyClusterClient, @@ -59,17 +38,26 @@ export function checkPrivilegesWithRequestFactory( return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], - privilegeOrPrivileges: string | string[] + privileges: CheckPrivilegesPayload ): Promise => { - const privileges = Array.isArray(privilegeOrPrivileges) - ? privilegeOrPrivileges - : [privilegeOrPrivileges]; - const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); + const kibanaPrivileges = Array.isArray(privileges.kibana) + ? privileges.kibana + : privileges.kibana + ? [privileges.kibana] + : []; + const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]); const hasPrivilegesResponse = (await clusterClient .asScoped(request) .callAsCurrentUser('shield.hasPrivileges', { body: { + cluster: privileges.elasticsearch?.cluster, + index: Object.entries(privileges.elasticsearch?.index ?? {}).map( + ([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + }) + ), applications: [ { application: applicationName, resources, privileges: allApplicationPrivileges }, ], @@ -85,6 +73,27 @@ export function checkPrivilegesWithRequestFactory( const applicationPrivilegesResponse = hasPrivilegesResponse.application[applicationName]; + const clusterPrivilegesResponse = hasPrivilegesResponse.cluster ?? {}; + + const clusterPrivileges = Object.entries(clusterPrivilegesResponse).map( + ([privilege, authorized]) => ({ + privilege, + authorized, + }) + ); + + const indexPrivileges = Object.entries(hasPrivilegesResponse.index ?? {}).reduce< + CheckPrivilegesResponse['privileges']['elasticsearch']['index'] + >((acc, [index, indexResponse]) => { + return { + ...acc, + [index]: Object.entries(indexResponse).map(([privilege, authorized]) => ({ + privilege, + authorized, + })), + }; + }, {}); + if (hasIncompatibleVersion(applicationPrivilegesResponse)) { throw new Error( 'Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.' @@ -93,7 +102,7 @@ export function checkPrivilegesWithRequestFactory( // we need to filter out the non requested privileges from the response const resourcePrivileges = transform(applicationPrivilegesResponse, (result, value, key) => { - result[key!] = pick(value, privileges); + result[key!] = pick(value, privileges.kibana ?? []); }) as HasPrivilegesResponseApplication; const privilegeArray = Object.entries(resourcePrivileges) .map(([key, val]) => { @@ -111,23 +120,29 @@ export function checkPrivilegesWithRequestFactory( return { hasAllRequested: hasPrivilegesResponse.has_all_requested, username: hasPrivilegesResponse.username, - privileges: privilegeArray, + privileges: { + kibana: privilegeArray, + elasticsearch: { + cluster: clusterPrivileges, + index: indexPrivileges, + }, + }, }; }; return { - async atSpace(spaceId: string, privilegeOrPrivileges: string | string[]) { + async atSpace(spaceId: string, privileges: CheckPrivilegesPayload) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResources([spaceResource], privilegeOrPrivileges); + return await checkPrivilegesAtResources([spaceResource], privileges); }, - async atSpaces(spaceIds: string[], privilegeOrPrivileges: string | string[]) { + async atSpaces(spaceIds: string[], privileges: CheckPrivilegesPayload) { const spaceResources = spaceIds.map((spaceId) => ResourceSerializer.serializeSpaceResource(spaceId) ); - return await checkPrivilegesAtResources(spaceResources, privilegeOrPrivileges); + return await checkPrivilegesAtResources(spaceResources, privileges); }, - async globally(privilegeOrPrivileges: string | string[]) { - return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privilegeOrPrivileges); + async globally(privileges: CheckPrivilegesPayload) { + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges); }, }; }; diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 2206748597635..093b308f59391 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -24,11 +24,13 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { namespaceToSpaceId: jest.fn(), }) )(request); - const result = await checkPrivilegesDynamically(privilegeOrPrivileges); + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { + kibana: privilegeOrPrivileges, + }); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -43,9 +45,9 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { mockCheckPrivilegesWithRequest, () => undefined )(request); - const result = await checkPrivilegesDynamically(privilegeOrPrivileges); + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: privilegeOrPrivileges }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 6014bad739e77..cd5961e5940ed 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -6,10 +6,11 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesResponse, CheckPrivilegesWithRequest } from './types'; +import { CheckPrivilegesPayload } from './types'; export type CheckPrivilegesDynamically = ( - privilegeOrPrivileges: string | string[] + privileges: CheckPrivilegesPayload ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( @@ -22,11 +23,11 @@ export function checkPrivilegesDynamicallyWithRequestFactory( ): CheckPrivilegesDynamicallyWithRequest { return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); - return async function checkPrivilegesDynamically(privilegeOrPrivileges: string | string[]) { + return async function checkPrivilegesDynamically(privileges: CheckPrivilegesPayload) { const spacesService = getSpacesService(); return spacesService - ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privilegeOrPrivileges) - : await checkPrivileges.globally(privilegeOrPrivileges); + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges) + : await checkPrivileges.globally(privileges); }; }; } diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index b393c4a9e1a04..f287cc04280ac 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -7,7 +7,7 @@ import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; import { httpServerMock } from '../../../../../src/core/server/mocks'; -import { CheckPrivileges, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivileges, CheckPrivilegesWithRequest } from './types'; import { SpacesService } from '../plugin'; let mockCheckPrivileges: jest.Mocked; @@ -69,7 +69,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledTimes(1); const spaceIds = mockSpacesService!.namespaceToSpaceId.mock.results.map((x) => x.value); - expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions); + expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, { kibana: actions }); }); test(`de-duplicates namespaces`, async () => { @@ -93,7 +93,7 @@ describe('#checkSavedObjectsPrivileges', () => { mockSpacesService!.namespaceToSpaceId(undefined), // deduplicated with 'default' mockSpacesService!.namespaceToSpaceId(namespace1), // deduplicated with namespace1 ]; - expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions); + expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, { kibana: actions }); }); }); @@ -112,7 +112,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpace).toHaveBeenCalledTimes(1); const spaceId = mockSpacesService!.namespaceToSpaceId.mock.results[0].value; - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, actions); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { kibana: actions }); }); test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { @@ -127,7 +127,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(actions); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); }); }); }); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index 6d2f724dae948..7c0ca7dcaa392 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -6,7 +6,7 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './check_privileges'; +import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './types'; export type CheckSavedObjectsPrivilegesWithRequest = ( request: KibanaRequest @@ -35,7 +35,7 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( const spacesService = getSpacesService(); if (!spacesService) { // Spaces disabled, authorizing globally - return await checkPrivilegesWithRequest(request).globally(actions); + return await checkPrivilegesWithRequest(request).globally({ kibana: actions }); } else if (Array.isArray(namespaceOrNamespaces)) { // Spaces enabled, authorizing against multiple spaces if (!namespaceOrNamespaces.length) { @@ -45,11 +45,11 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)) ); - return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, { kibana: actions }); } else { // Spaces enabled, authorizing against a single space const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); - return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); + return await checkPrivilegesWithRequest(request).atSpace(spaceId, { kibana: actions }); } }; }; diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index f9405214aac5a..98faae6edab2c 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -9,11 +9,17 @@ import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { httpServerMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; -import { Feature } from '../../../features/server'; +import { KibanaFeature, ElasticsearchFeature } from '../../../features/server'; +import { AuthenticatedUser } from '..'; +import { CheckPrivilegesResponse } from './types'; type MockAuthzOptions = | { rejectCheckPrivileges: any } - | { resolveCheckPrivileges: { privileges: Array<{ privilege: string; authorized: boolean }> } }; + | { + resolveCheckPrivileges: { + privileges: CheckPrivilegesResponse['privileges']; + }; + }; const actions = new Actions('1.0.0-zeta1'); const mockRequest = httpServerMock.createKibanaRequest(); @@ -31,14 +37,34 @@ const createMockAuthz = (options: MockAuthzOptions) => { throw options.rejectCheckPrivileges; } - const expected = options.resolveCheckPrivileges.privileges.map((x) => x.privilege); - expect(checkActions).toEqual(expected); + const expectedKibana = options.resolveCheckPrivileges.privileges.kibana.map( + (x) => x.privilege + ); + const expectedCluster = ( + options.resolveCheckPrivileges.privileges.elasticsearch.cluster ?? [] + ).map((x) => x.privilege); + + expect(checkActions).toEqual({ + kibana: expectedKibana, + elasticsearch: { cluster: expectedCluster, index: {} }, + }); return options.resolveCheckPrivileges; }); }); + mock.checkElasticsearchPrivilegesWithRequest.mockImplementation((request) => { + expect(request).toBe(mockRequest); + return jest.fn().mockImplementation((privileges) => {}); + }); return mock; }; +const createMockUser = (user: Partial = {}) => + ({ + username: 'mock_user', + roles: [], + ...user, + } as AuthenticatedUser); + describe('usingPrivileges', () => { describe('checkPrivileges errors', () => { test(`disables uiCapabilities when a 401 is thrown`, async () => { @@ -50,16 +76,28 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - new Feature({ + new KibanaFeature({ id: 'fooFeature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', app: ['fooApp', 'foo'], navLinkId: 'foo', privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: [], + }, + ], + }), + ], mockLoggers.get(), - mockAuthz + mockAuthz, + createMockUser() ); const result = await usingPrivileges( @@ -126,16 +164,28 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - new Feature({ + new KibanaFeature({ id: 'fooFeature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', app: ['foo'], navLinkId: 'foo', privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: [], + }, + ], + }), + ], mockLoggers.get(), - mockAuthz + mockAuthz, + createMockUser() ); const result = await usingPrivileges( @@ -199,8 +249,10 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [], + [], mockLoggers.get(), - mockAuthz + mockAuthz, + createMockUser() ); await expect( @@ -234,40 +286,91 @@ describe('usingPrivileges', () => { test(`disables ui capabilities when they don't have privileges`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: [ - { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, - { privilege: actions.ui.get('navLinks', 'bar'), authorized: false }, - { privilege: actions.ui.get('navLinks', 'quz'), authorized: false }, - { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, - { privilege: actions.ui.get('management', 'kibana', 'settings'), authorized: false }, - { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'bar'), authorized: false }, - { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'bar'), authorized: false }, - ], + privileges: { + kibana: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: false }, + { privilege: actions.ui.get('navLinks', 'quz'), authorized: false }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'settings'), authorized: false }, + { + privilege: actions.ui.get('management', 'kibana', 'esManagement'), + authorized: false, + }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: false }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: false }, + ], + elasticsearch: { + cluster: [ + { privilege: 'manage', authorized: false }, + { privilege: 'monitor', authorized: true }, + { privilege: 'manage_security', authorized: true }, + ], + index: {}, + }, + }, }, }); const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - new Feature({ + new KibanaFeature({ id: 'fooFeature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', navLinkId: 'foo', app: [], privileges: null, }), - new Feature({ + new KibanaFeature({ id: 'barFeature', - name: 'Bar Feature', + name: 'Bar KibanaFeature', navLinkId: 'bar', app: ['bar'], privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: [ + { + requiredClusterPrivileges: ['manage'], + ui: ['es_manage'], + }, + { + requiredClusterPrivileges: ['monitor'], + ui: ['es_monitor'], + }, + ], + }), + new ElasticsearchFeature({ + id: 'esSecurityFeature', + privileges: [ + { + requiredClusterPrivileges: ['manage_security'], + ui: ['es_manage_sec'], + }, + ], + }), + new ElasticsearchFeature({ + id: 'esManagementFeature', + management: { + kibana: ['esManagement'], + }, + privileges: [ + { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + ], + }), + ], loggingSystemMock.create().get(), - mockAuthz + mockAuthz, + createMockUser() ); const result = await usingPrivileges( @@ -281,6 +384,7 @@ describe('usingPrivileges', () => { kibana: { indices: true, settings: false, + esManagement: true, }, }, catalogue: {}, @@ -292,6 +396,14 @@ describe('usingPrivileges', () => { foo: true, bar: true, }, + esFeature: { + es_manage: true, + es_monitor: true, + }, + esSecurityFeature: { + es_manage_sec: true, + }, + esManagementFeature: {}, }) ); @@ -305,6 +417,7 @@ describe('usingPrivileges', () => { kibana: { indices: true, settings: false, + esManagement: true, }, }, catalogue: {}, @@ -316,44 +429,70 @@ describe('usingPrivileges', () => { foo: true, bar: false, }, + esFeature: { + es_manage: false, + es_monitor: true, + }, + esSecurityFeature: { + es_manage_sec: true, + }, + esManagementFeature: {}, }); }); test(`doesn't re-enable disabled uiCapabilities`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: [ - { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, - { privilege: actions.ui.get('navLinks', 'bar'), authorized: true }, - { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'bar'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'bar'), authorized: true }, - ], + privileges: { + kibana: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: true }, + ], + elasticsearch: { + cluster: [], + index: {}, + }, + }, }, }); const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - new Feature({ + new KibanaFeature({ id: 'fooFeature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', navLinkId: 'foo', app: [], privileges: null, }), - new Feature({ + new KibanaFeature({ id: 'barFeature', - name: 'Bar Feature', + name: 'Bar KibanaFeature', navLinkId: 'bar', app: [], privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: [], + }, + ], + }), + ], loggingSystemMock.create().get(), - mockAuthz + mockAuthz, + createMockUser() ); const result = await usingPrivileges( @@ -409,16 +548,28 @@ describe('all', () => { const { all } = disableUICapabilitiesFactory( mockRequest, [ - new Feature({ + new KibanaFeature({ id: 'fooFeature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', app: ['foo'], navLinkId: 'foo', privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: [ + { + requiredClusterPrivileges: [], + ui: ['bar'], + }, + ], + }), + ], loggingSystemMock.create().get(), - mockAuthz + mockAuthz, + createMockUser() ); const result = all( @@ -441,6 +592,9 @@ describe('all', () => { foo: true, bar: true, }, + esFeature: { + bar: true, + }, }) ); expect(result).toEqual({ @@ -462,6 +616,9 @@ describe('all', () => { foo: false, bar: false, }, + esFeature: { + bar: false, + }, }); }); }); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index 41d596d570fb9..89cc9065655cd 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -5,18 +5,26 @@ */ import { flatten, isObject, mapValues } from 'lodash'; +import { RecursiveReadonly, RecursiveReadonlyArray } from '@kbn/utility-types'; import type { Capabilities as UICapabilities } from '../../../../../src/core/types'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; -import { Feature } from '../../../features/server'; +import { + KibanaFeature, + ElasticsearchFeature, + FeatureElasticsearchPrivileges, +} from '../../../features/server'; -import { CheckPrivilegesResponse } from './check_privileges'; +import { CheckPrivilegesResponse } from './types'; import { AuthorizationServiceSetup } from '.'; +import { AuthenticatedUser } from '..'; export function disableUICapabilitiesFactory( request: KibanaRequest, - features: Feature[], + features: KibanaFeature[], + elasticsearchFeatures: ElasticsearchFeature[], logger: Logger, - authz: AuthorizationServiceSetup + authz: AuthorizationServiceSetup, + user: AuthenticatedUser | null ) { // nav links are sourced from the apps property. // The Kibana Platform associates nav links to the app which registers it, in a 1:1 relationship. @@ -25,6 +33,39 @@ export function disableUICapabilitiesFactory( .flatMap((feature) => feature.app) .filter((navLinkId) => navLinkId != null); + const elasticsearchFeatureMap = elasticsearchFeatures.reduce< + Record> + >((acc, esFeature) => { + return { + ...acc, + [esFeature.id]: esFeature.privileges, + }; + }, {}); + + const allRequiredClusterPrivileges = Array.from( + new Set( + Object.values(elasticsearchFeatureMap) + .flat() + .map((p) => p.requiredClusterPrivileges) + .flat() + ) + ); + + const allRequiredIndexPrivileges = Object.values(elasticsearchFeatureMap) + .flat() + .filter((p) => !!p.requiredIndexPrivileges) + .reduce>((acc, p) => { + return { + ...acc, + ...Object.entries(p.requiredIndexPrivileges!).reduce((acc2, [indexName, privileges]) => { + return { + ...acc2, + [indexName]: [...(acc[indexName] ?? []), ...privileges], + }; + }, {}), + }; + }, {}); + const shouldDisableFeatureUICapability = ( featureId: keyof UICapabilities, uiCapability: string @@ -59,6 +100,12 @@ export function disableUICapabilitiesFactory( uiCapability: string, value: boolean | Record ): string[] { + // Capabilities derived from Elasticsearch features should not be + // included here, as the result is used to check authorization against + // Kibana Privileges, rather than Elasticsearch Privileges. + if (elasticsearchFeatureMap.hasOwnProperty(featureId)) { + return []; + } if (typeof value === 'boolean') { return [authz.actions.ui.get(featureId, uiCapability)]; } @@ -85,7 +132,13 @@ export function disableUICapabilitiesFactory( let checkPrivilegesResponse: CheckPrivilegesResponse; try { const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); - checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); + checkPrivilegesResponse = await checkPrivilegesDynamically({ + kibana: uiActions, + elasticsearch: { + cluster: allRequiredClusterPrivileges, + index: allRequiredIndexPrivileges, + }, + }); } catch (err) { // if we get a 401/403, then we want to disable all uiCapabilities, as this // is generally when the user hasn't authenticated yet and we're displaying the @@ -110,9 +163,65 @@ export function disableUICapabilitiesFactory( } const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); - return checkPrivilegesResponse.privileges.some( - (x) => x.privilege === action && x.authorized === true - ); + + const isElasticsearchFeature = elasticsearchFeatureMap.hasOwnProperty(featureId); + const isCatalogueFeature = featureId === 'catalogue'; + const isManagementFeature = featureId === 'management'; + + if (!isElasticsearchFeature) { + const hasRequiredKibanaPrivileges = checkPrivilegesResponse.privileges.kibana.some( + (x) => x.privilege === action && x.authorized === true + ); + + // Catalogue and management capbility buckets can also be influenced by ES privileges, + // so the early return is not possible for these. + if ((!isCatalogueFeature && !isManagementFeature) || hasRequiredKibanaPrivileges) { + return hasRequiredKibanaPrivileges; + } + } + + return elasticsearchFeatures.some((esFeature) => { + if (isCatalogueFeature) { + const [catalogueEntry] = uiCapabilityParts; + const featureGrantsCatalogueEntry = (esFeature.catalogue ?? []).includes(catalogueEntry); + return ( + featureGrantsCatalogueEntry && + hasAnyRequiredElasticsearchPrivilegesForFeature( + esFeature, + checkPrivilegesResponse, + user + ) + ); + } else if (isManagementFeature) { + const [managementSectionId, managementEntryId] = uiCapabilityParts; + const featureGrantsManagementEntry = + (esFeature.management ?? {}).hasOwnProperty(managementSectionId) && + esFeature.management![managementSectionId].includes(managementEntryId); + + return ( + featureGrantsManagementEntry && + hasAnyRequiredElasticsearchPrivilegesForFeature( + esFeature, + checkPrivilegesResponse, + user + ) + ); + } else if (esFeature.id === featureId) { + if (uiCapabilityParts.length !== 1) { + // The current privilege system does not allow for this to happen. + // This is a safeguard against future changes. + throw new Error( + `Elasticsearch feature ${esFeature.id} expected a single capability, but found ${uiCapabilityParts.length}` + ); + } + return hasRequiredElasticsearchPrivilegesForCapability( + esFeature, + uiCapabilityParts[0], + checkPrivilegesResponse, + user + ); + } + }); }; return mapValues(uiCapabilities, (featureUICapabilities, featureId) => { @@ -151,3 +260,56 @@ export function disableUICapabilitiesFactory( usingPrivileges, }; } + +function hasRequiredElasticsearchPrivilegesForCapability( + esFeature: ElasticsearchFeature, + uiCapability: string, + checkPrivilegesResponse: CheckPrivilegesResponse, + user: AuthenticatedUser | null +) { + return esFeature.privileges.some((privilege) => { + const privilegeGrantsCapability = privilege.ui.includes(uiCapability); + if (!privilegeGrantsCapability) { + return false; + } + + return isGrantedElasticsearchPrivilege(privilege, checkPrivilegesResponse, user); + }); +} + +function hasAnyRequiredElasticsearchPrivilegesForFeature( + esFeature: ElasticsearchFeature, + checkPrivilegesResponse: CheckPrivilegesResponse, + user: AuthenticatedUser | null +) { + return esFeature.privileges.some((privilege) => { + return isGrantedElasticsearchPrivilege(privilege, checkPrivilegesResponse, user); + }); +} + +function isGrantedElasticsearchPrivilege( + privilege: RecursiveReadonly, + checkPrivilegesResponse: CheckPrivilegesResponse, + user: AuthenticatedUser | null +) { + const hasRequiredClusterPrivileges = privilege.requiredClusterPrivileges.every( + (expectedClusterPriv) => + checkPrivilegesResponse.privileges.elasticsearch.cluster.some( + (x) => x.privilege === expectedClusterPriv && x.authorized === true + ) + ); + + const hasRequiredIndexPrivileges = Object.entries(privilege.requiredIndexPrivileges ?? {}).every( + ([indexName, requiredIndexPrivileges]) => { + return checkPrivilegesResponse.privileges.elasticsearch.index[indexName] + .filter((indexResponse) => requiredIndexPrivileges.includes(indexResponse.privilege)) + .every((indexResponse) => indexResponse.authorized); + } + ); + + const hasRequiredRoles = (privilege.requiredRoles ?? []).every( + (requiredRole) => user?.roles.includes(requiredRole) ?? false + ); + + return hasRequiredClusterPrivileges && hasRequiredIndexPrivileges && hasRequiredRoles; +} diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts index 62b254d132d9e..6cb78a3001a9b 100644 --- a/x-pack/plugins/security/server/authorization/index.mock.ts +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -13,6 +13,7 @@ export const authorizationMock = { }: { version?: string; applicationName?: string } = {}) => ({ actions: actionsMock.create(version), checkPrivilegesWithRequest: jest.fn(), + checkElasticsearchPrivilegesWithRequest: jest.fn(), checkPrivilegesDynamicallyWithRequest: jest.fn(), checkSavedObjectsPrivilegesWithRequest: jest.fn(), applicationName, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 5e9c1818cad2b..dc261e2eec982 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -6,7 +6,7 @@ import { Actions } from '../../actions'; import { FeaturePrivilegeAlertingBuilder } from './alerting'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; const version = '1.0.0-zeta1'; @@ -29,7 +29,7 @@ describe(`feature_privilege_builder`, () => { ui: [], }; - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'my-feature', name: 'my-feature', app: [], @@ -60,7 +60,7 @@ describe(`feature_privilege_builder`, () => { ui: [], }; - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'my-feature', name: 'my-feature', app: [], @@ -97,7 +97,7 @@ describe(`feature_privilege_builder`, () => { ui: [], }; - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'my-feature', name: 'my-feature', app: [], @@ -144,7 +144,7 @@ describe(`feature_privilege_builder`, () => { ui: [], }; - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'my-feature', name: 'my-feature', app: [], diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index eb278a5755204..fa9cadf2aea62 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -5,7 +5,7 @@ */ import { uniq } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['get', 'getAlertState', 'getAlertInstanceSummary', 'find']; @@ -24,7 +24,10 @@ const writeOperations: string[] = [ const allOperations: string[] = [...readOperations, ...writeOperations]; export class FeaturePrivilegeAlertingBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions( + privilegeDefinition: FeatureKibanaPrivileges, + feature: KibanaFeature + ): string[] { const getAlertingPrivilege = ( operations: string[], privilegedTypes: readonly string[], diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts index 6b7d94bb0127e..0e63cdceffc57 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeApiBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { if (privilegeDefinition.api) { return privilegeDefinition.api.map((operation) => this.actions.api.get(operation)); } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index 213aa83f2d26e..bf6b0e60f1045 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { const appIds = privilegeDefinition.app; if (!appIds) { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index f1ea7091b9481..97a3c9c1e336e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { const catalogueEntries = privilegeDefinition.catalogue; if (!catalogueEntries) { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts index 172ab24eb7e51..0eded66d65b06 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; export interface FeaturePrivilegeBuilder { - getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[]; + getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: KibanaFeature): string[]; } export abstract class BaseFeaturePrivilegeBuilder implements FeaturePrivilegeBuilder { constructor(protected readonly actions: Actions) {} public abstract getActions( privilegeDefinition: FeatureKibanaPrivileges, - feature: Feature + feature: KibanaFeature ): string[]; } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 76b664cbbe2a7..998fbc5cc5e24 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -5,7 +5,7 @@ */ import { flatten } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; import { FeaturePrivilegeApiBuilder } from './api'; import { FeaturePrivilegeAppBuilder } from './app'; @@ -31,7 +31,7 @@ export const featurePrivilegeBuilderFactory = (actions: Actions): FeaturePrivile ]; return { - getActions(privilege: FeatureKibanaPrivileges, feature: Feature) { + getActions(privilege: FeatureKibanaPrivileges, feature: KibanaFeature) { return flatten(builders.map((builder) => builder.getActions(privilege, feature))); }, }; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index be784949dc2fa..67b8cdb7616d4 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { const managementSections = privilegeDefinition.management; if (!managementSections) { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts index a6e5a01c7dba8..7400675ed17f3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeNavlinkBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { return (privilegeDefinition.app ?? []).map((app) => this.actions.ui.get('navLinks', app)); } } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 2c325fc8c6cb7..0dd89f2c5f3c1 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -5,7 +5,7 @@ */ import { flatten, uniq } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['bulk_get', 'get', 'find']; @@ -13,7 +13,7 @@ const writeOperations: string[] = ['create', 'bulk_create', 'update', 'bulk_upda const allOperations: string[] = [...readOperations, ...writeOperations]; export class FeaturePrivilegeSavedObjectBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions(privilegeDefinition: FeatureKibanaPrivileges): string[] { return uniq([ ...flatten( privilegeDefinition.savedObject.all.map((type) => [ diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts index 31bc351206e54..dd167a291f11d 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeUIBuilder extends BaseFeaturePrivilegeBuilder { - public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + public getActions( + privilegeDefinition: FeatureKibanaPrivileges, + feature: KibanaFeature + ): string[] { return privilegeDefinition.ui.map((ui) => this.actions.ui.get(feature.id, ui)); } } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts index bb1f0c33fdee9..033040fd2f14b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../features/server'; +import { KibanaFeature } from '../../../../../features/server'; import { featurePrivilegeIterator } from './feature_privilege_iterator'; describe('featurePrivilegeIterator', () => { it('handles features with no privileges', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', privileges: null, @@ -26,7 +26,7 @@ describe('featurePrivilegeIterator', () => { }); it('handles features with no sub-features', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', privileges: { @@ -117,7 +117,7 @@ describe('featurePrivilegeIterator', () => { }); it('filters privileges using the provided predicate', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', privileges: { @@ -190,7 +190,7 @@ describe('featurePrivilegeIterator', () => { }); it('ignores sub features when `augmentWithSubFeaturePrivileges` is false', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -313,7 +313,7 @@ describe('featurePrivilegeIterator', () => { }); it('ignores sub features when `includeIn` is none, even if `augmentWithSubFeaturePrivileges` is true', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -436,7 +436,7 @@ describe('featurePrivilegeIterator', () => { }); it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: read`', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -563,7 +563,7 @@ describe('featurePrivilegeIterator', () => { }); it('does not duplicate privileges when merging', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -686,7 +686,7 @@ describe('featurePrivilegeIterator', () => { }); it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: all`', () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -811,7 +811,7 @@ describe('featurePrivilegeIterator', () => { }); it(`can augment primary feature privileges even if they don't specify their own`, () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -919,7 +919,7 @@ describe('featurePrivilegeIterator', () => { }); it(`can augment primary feature privileges even if the sub-feature privileges don't specify their own`, () => { - const feature = new Feature({ + const feature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts index 17c9464b14756..dba33f7a4f360 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -5,7 +5,7 @@ */ import _ from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; interface IteratorOptions { @@ -14,7 +14,7 @@ interface IteratorOptions { } export function* featurePrivilegeIterator( - feature: Feature, + feature: KibanaFeature, options: IteratorOptions ): IterableIterator<{ privilegeId: string; privilege: FeatureKibanaPrivileges }> { for (const entry of Object.entries(feature.privileges ?? {})) { @@ -35,7 +35,7 @@ export function* featurePrivilegeIterator( function mergeWithSubFeatures( privilegeId: string, privilege: FeatureKibanaPrivileges, - feature: Feature + feature: KibanaFeature ) { const mergedConfig = _.cloneDeep(privilege); for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts index b288262be25c6..d54b6d458d913 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SubFeaturePrivilegeConfig } from '../../../../../features/common'; -import { Feature } from '../../../../../features/server'; +import { KibanaFeature, SubFeaturePrivilegeConfig } from '../../../../../features/common'; export function* subFeaturePrivilegeIterator( - feature: Feature + feature: KibanaFeature ): IterableIterator { for (const subFeature of feature.subFeatures) { for (const group of subFeature.privilegeGroups) { diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 89ac73c220756..dd8ac44386dbd 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../features/server'; +import { KibanaFeature } from '../../../../features/server'; import { Actions } from '../actions'; import { privilegesFactory } from './privileges'; @@ -14,10 +14,10 @@ const actions = new Actions('1.0.0-zeta1'); describe('features', () => { test('actions defined at the feature do not cascade to the privileges', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo-feature', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], @@ -45,7 +45,7 @@ describe('features', () => { ]; const mockFeaturesService = featuresPluginMock.createSetup(); - mockFeaturesService.getFeatures.mockReturnValue(features); + mockFeaturesService.getKibanaFeatures.mockReturnValue(features); const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), @@ -60,10 +60,10 @@ describe('features', () => { }); test(`actions only specified at the privilege are alright too`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -85,13 +85,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const expectedAllPrivileges = [ actions.login, @@ -159,23 +159,23 @@ describe('features', () => { }); test(`features with no privileges aren't listed`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: null, }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('features.foo'); @@ -200,10 +200,10 @@ describe('features', () => { ].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], @@ -238,13 +238,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -256,6 +256,7 @@ describe('features', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), ] : []), ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), @@ -319,10 +320,10 @@ describe('features', () => { }); test('actions defined in a feature privilege with name `read` are included in `read`', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], @@ -357,13 +358,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.read`, [ @@ -401,10 +402,10 @@ describe('features', () => { }); test('actions defined in a reserved privilege are not included in `all` or `read`', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], @@ -431,13 +432,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -449,6 +450,7 @@ describe('features', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), ] : []), ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), @@ -457,10 +459,10 @@ describe('features', () => { }); test('actions defined in a feature with excludeFromBasePrivileges are not included in `all` or `read', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', excludeFromBasePrivileges: true, icon: 'arrowDown', navLinkId: 'kibana:foo', @@ -496,13 +498,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -514,6 +516,7 @@ describe('features', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), ] : []), ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), @@ -522,10 +525,10 @@ describe('features', () => { }); test('actions defined in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], @@ -562,13 +565,13 @@ describe('features', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -580,6 +583,7 @@ describe('features', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), ] : []), ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), @@ -591,10 +595,10 @@ describe('features', () => { describe('reserved', () => { test('actions defined at the feature do not cascade to the privileges', () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], @@ -621,23 +625,23 @@ describe('reserved', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [actions.version]); }); test(`actions only specified at the privilege are alright too`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: null, @@ -659,13 +663,13 @@ describe('reserved', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ @@ -698,10 +702,10 @@ describe('reserved', () => { }); test(`features with no reservedPrivileges aren't listed`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -723,13 +727,13 @@ describe('reserved', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('reserved.foo'); @@ -739,10 +743,10 @@ describe('reserved', () => { describe('subFeatures', () => { describe(`with includeIn: 'none'`, () => { test(`should not augment the primary feature privileges, base privileges, or minimal feature privileges`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -786,13 +790,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ @@ -841,6 +845,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), actions.ui.get('foo', 'foo'), ]); @@ -865,10 +870,10 @@ describe('subFeatures', () => { describe(`with includeIn: 'read'`, () => { test(`should augment the primary feature privileges and base privileges, but never the minimal versions`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -912,13 +917,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ @@ -993,6 +998,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -1063,10 +1069,10 @@ describe('subFeatures', () => { }); test(`should augment the primary feature privileges, but not base privileges if feature is excluded from them.`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], excludeFromBasePrivileges: true, @@ -1111,13 +1117,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ @@ -1192,6 +1198,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1203,10 +1210,10 @@ describe('subFeatures', () => { describe(`with includeIn: 'all'`, () => { test(`should augment the primary 'all' feature privileges and base 'all' privileges, but never the minimal versions`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -1250,13 +1257,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ @@ -1319,6 +1326,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -1365,10 +1373,10 @@ describe('subFeatures', () => { }); test(`should augment the primary 'all' feature privileges, but not the base privileges if the feature is excluded from them`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], excludeFromBasePrivileges: true, @@ -1413,13 +1421,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ @@ -1482,6 +1490,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1493,10 +1502,10 @@ describe('subFeatures', () => { describe(`when license does not allow sub features`, () => { test(`should augment the primary feature privileges, and should not create minimal or sub-feature privileges`, () => { - const features: Feature[] = [ - new Feature({ + const features: KibanaFeature[] = [ + new KibanaFeature({ id: 'foo', - name: 'Foo Feature', + name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], privileges: { @@ -1540,13 +1549,13 @@ describe('subFeatures', () => { }), ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: false }), }; - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual.features).not.toHaveProperty(`foo.subFeaturePriv1`); @@ -1598,6 +1607,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5d8ef3f376cac..24b46222e7f35 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -6,7 +6,10 @@ import { uniq } from 'lodash'; import { SecurityLicense } from '../../../common/licensing'; -import { Feature, PluginSetupContract as FeaturesPluginSetup } from '../../../../features/server'; +import { + KibanaFeature, + PluginSetupContract as FeaturesPluginSetup, +} from '../../../../features/server'; import { RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; @@ -28,7 +31,7 @@ export function privilegesFactory( return { get() { - const features = featuresService.getFeatures(); + const features = featuresService.getKibanaFeatures(); const { allowSubFeaturePrivileges } = licenseService.getFeatures(); const basePrivilegeFeatures = features.filter( (feature) => !feature.excludeFromBasePrivileges @@ -100,6 +103,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), actions.ui.get('enterpriseSearch', 'all'), ...allActions, ], @@ -109,7 +113,7 @@ export function privilegesFactory( all: [actions.login, actions.version, ...allActions], read: [actions.login, actions.version, ...readActions], }, - reserved: features.reduce((acc: Record, feature: Feature) => { + reserved: features.reduce((acc: Record, feature: KibanaFeature) => { if (feature.reserved) { feature.reserved.privileges.forEach((reservedPrivilege) => { acc[reservedPrivilege.id] = [ diff --git a/x-pack/plugins/security/server/authorization/types.ts b/x-pack/plugins/security/server/authorization/types.ts index 75188d1191b1a..bedf46862e4f5 100644 --- a/x-pack/plugins/security/server/authorization/types.ts +++ b/x-pack/plugins/security/server/authorization/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KibanaRequest } from 'src/core/server'; + export interface HasPrivilegesResponseApplication { [resource: string]: { [privilegeName: string]: boolean; @@ -16,4 +18,58 @@ export interface HasPrivilegesResponse { application: { [applicationName: string]: HasPrivilegesResponseApplication; }; + cluster?: { + [privilegeName: string]: boolean; + }; + index?: { + [indexName: string]: { + [privilegeName: string]: boolean; + }; + }; +} + +export interface CheckPrivilegesResponse { + hasAllRequested: boolean; + username: string; + privileges: { + kibana: Array<{ + /** + * If this attribute is undefined, this element is a privilege for the global resource. + */ + resource?: string; + privilege: string; + authorized: boolean; + }>; + elasticsearch: { + cluster: Array<{ + privilege: string; + authorized: boolean; + }>; + index: { + [indexName: string]: Array<{ + privilege: string; + authorized: boolean; + }>; + }; + }; + }; +} + +export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; + +export interface CheckPrivileges { + atSpace(spaceId: string, privileges: CheckPrivilegesPayload): Promise; + atSpaces( + spaceIds: string[], + privileges: CheckPrivilegesPayload + ): Promise; + globally(privileges: CheckPrivilegesPayload): Promise; +} + +export interface CheckPrivilegesPayload { + kibana?: string | string[]; + elasticsearch?: { + cluster: string[]; + index: Record; + }; } diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index cd2c7faa263c9..8e6d72670c8d9 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; import { validateFeaturePrivileges } from './validate_feature_privileges'; it('allows features to be defined without privileges', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -19,7 +19,7 @@ it('allows features to be defined without privileges', () => { }); it('allows features with reserved privileges to be defined', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -45,7 +45,7 @@ it('allows features with reserved privileges to be defined', () => { }); it('allows features with sub-features to be defined', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -108,7 +108,7 @@ it('allows features with sub-features to be defined', () => { }); it('does not allow features with sub-features which have id conflicts with the minimal privileges', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -153,12 +153,12 @@ it('does not allow features with sub-features which have id conflicts with the m }); expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( - `"Feature 'foo' already has a privilege with ID 'minimal_all'. Sub feature 'sub-feature-1' cannot also specify this."` + `"KibanaFeature 'foo' already has a privilege with ID 'minimal_all'. Sub feature 'sub-feature-1' cannot also specify this."` ); }); it('does not allow features with sub-features which have id conflicts with the primary feature privileges', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -203,12 +203,12 @@ it('does not allow features with sub-features which have id conflicts with the p }); expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( - `"Feature 'foo' already has a privilege with ID 'read'. Sub feature 'sub-feature-1' cannot also specify this."` + `"KibanaFeature 'foo' already has a privilege with ID 'read'. Sub feature 'sub-feature-1' cannot also specify this."` ); }); it('does not allow features with sub-features which have id conflicts each other', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -273,6 +273,6 @@ it('does not allow features with sub-features which have id conflicts each other }); expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( - `"Feature 'foo' already has a privilege with ID 'some-sub-feature'. Sub feature 'sub-feature-2' cannot also specify this."` + `"KibanaFeature 'foo' already has a privilege with ID 'some-sub-feature'. Sub feature 'sub-feature-2' cannot also specify this."` ); }); diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 79e5348b4ac64..eeb9c4cb74314 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; -export function validateFeaturePrivileges(features: Feature[]) { +export function validateFeaturePrivileges(features: KibanaFeature[]) { for (const feature of features) { const seenPrivilegeIds = new Set(); Object.keys(feature.privileges ?? {}).forEach((privilegeId) => { @@ -20,7 +20,7 @@ export function validateFeaturePrivileges(features: Feature[]) { subFeaturePrivilegeGroup.privileges.forEach((subFeaturePrivilege) => { if (seenPrivilegeIds.has(subFeaturePrivilege.id)) { throw new Error( - `Feature '${feature.id}' already has a privilege with ID '${subFeaturePrivilege.id}'. Sub feature '${subFeature.name}' cannot also specify this.` + `KibanaFeature '${feature.id}' already has a privilege with ID '${subFeaturePrivilege.id}'. Sub feature '${subFeature.name}' cannot also specify this.` ); } seenPrivilegeIds.add(subFeaturePrivilege.id); diff --git a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts index 26af0dadfb288..d91a4d4151316 100644 --- a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; import { validateReservedPrivileges } from './validate_reserved_privileges'; it('allows features to be defined without privileges', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -19,7 +19,7 @@ it('allows features to be defined without privileges', () => { }); it('allows features with a single reserved privilege to be defined', () => { - const feature: Feature = new Feature({ + const feature: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -45,7 +45,7 @@ it('allows features with a single reserved privilege to be defined', () => { }); it('allows multiple features with reserved privileges to be defined', () => { - const feature1: Feature = new Feature({ + const feature1: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -67,7 +67,7 @@ it('allows multiple features with reserved privileges to be defined', () => { }, }); - const feature2: Feature = new Feature({ + const feature2: KibanaFeature = new KibanaFeature({ id: 'foo2', name: 'foo', app: [], @@ -93,7 +93,7 @@ it('allows multiple features with reserved privileges to be defined', () => { }); it('prevents a feature from specifying the same reserved privilege id', () => { - const feature1: Feature = new Feature({ + const feature1: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -131,7 +131,7 @@ it('prevents a feature from specifying the same reserved privilege id', () => { }); it('prevents features from sharing a reserved privilege id', () => { - const feature1: Feature = new Feature({ + const feature1: KibanaFeature = new KibanaFeature({ id: 'foo', name: 'foo', app: [], @@ -153,7 +153,7 @@ it('prevents features from sharing a reserved privilege id', () => { }, }); - const feature2: Feature = new Feature({ + const feature2: KibanaFeature = new KibanaFeature({ id: 'foo2', name: 'foo', app: [], diff --git a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts index 0915308fc0f89..23e5c28a4af1b 100644 --- a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; -export function validateReservedPrivileges(features: Feature[]) { +export function validateReservedPrivileges(features: KibanaFeature[]) { const seenPrivilegeIds = new Set(); for (const feature of features) { diff --git a/x-pack/plugins/security/server/features/index.ts b/x-pack/plugins/security/server/features/index.ts new file mode 100644 index 0000000000000..3fe097c2bec12 --- /dev/null +++ b/x-pack/plugins/security/server/features/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { securityFeatures } from './security_features'; diff --git a/x-pack/plugins/security/server/features/security_features.ts b/x-pack/plugins/security/server/features/security_features.ts new file mode 100644 index 0000000000000..d80314c077aa2 --- /dev/null +++ b/x-pack/plugins/security/server/features/security_features.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchFeatureConfig } from '../../../features/server'; + +const userManagementFeature: ElasticsearchFeatureConfig = { + id: 'users', + management: { + security: ['users'], + }, + catalogue: ['security'], + privileges: [ + { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + ], +}; + +const rolesManagementFeature: ElasticsearchFeatureConfig = { + id: 'roles', + management: { + security: ['roles'], + }, + catalogue: ['security'], + privileges: [ + { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + ], +}; + +const apiKeysManagementFeature: ElasticsearchFeatureConfig = { + id: 'api_keys', + management: { + security: ['api_keys'], + }, + catalogue: ['security'], + privileges: [ + { + requiredClusterPrivileges: ['manage_api_key'], + ui: [], + }, + { + requiredClusterPrivileges: ['manage_own_api_key'], + ui: [], + }, + ], +}; + +const roleMappingsManagementFeature: ElasticsearchFeatureConfig = { + id: 'role_mappings', + management: { + security: ['role_mappings'], + }, + catalogue: ['security'], + privileges: [ + { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + ], +}; + +export const securityFeatures = [ + userManagementFeature, + rolesManagementFeature, + apiKeysManagementFeature, + roleMappingsManagementFeature, +]; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 9825e77b164c8..9088d4f08d0ef 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -11,6 +11,7 @@ import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; describe('Security Plugin', () => { @@ -44,6 +45,7 @@ describe('Security Plugin', () => { mockDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, + features: featuresPluginMock.createSetup(), taskManager: taskManagerMock.createSetup(), } as unknown) as PluginSetupDependencies; }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1eb406dd2061b..dc9139473004b 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -16,6 +16,7 @@ import { PluginInitializerContext, } from '../../../../src/core/server'; import { SpacesPluginSetup } from '../../spaces/server'; +import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; import { PluginSetupContract as FeaturesPluginSetup, PluginStartContract as FeaturesPluginStart, @@ -31,6 +32,7 @@ import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; +import { securityFeatures } from './features'; import { ElasticsearchService } from './elasticsearch'; import { SessionManagementService } from './session_management'; import { registerSecurityUsageCollector } from './usage_collector'; @@ -40,6 +42,11 @@ export type SpacesService = Pick< 'getSpaceId' | 'namespaceToSpaceId' >; +export type FeaturesService = Pick< + FeaturesSetupContract, + 'getKibanaFeatures' | 'getElasticsearchFeatures' +>; + /** * Describes public Security plugin contract returned at the `setup` stage. */ @@ -146,6 +153,10 @@ export class Plugin { license$: licensing.license$, }); + securityFeatures.forEach((securityFeature) => + features.registerElasticsearchFeature(securityFeature) + ); + const { clusterClient } = this.elasticsearchService.setup({ elasticsearch: core.elasticsearch, license, @@ -188,6 +199,7 @@ export class Plugin { packageVersion: this.initializerContext.env.packageInfo.version, getSpacesService: this.getSpacesService, features, + getCurrentUser: authc.getCurrentUser, }); setupSavedObjects({ @@ -211,7 +223,7 @@ export class Plugin { getFeatures: () => core .getStartServices() - .then(([, { features: featuresStart }]) => featuresStart.getFeatures()), + .then(([, { features: featuresStart }]) => featuresStart.getKibanaFeatures()), getFeatureUsageService: this.getFeatureUsageService, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index 8f115f11329d3..6e9b88f30479f 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -15,7 +15,7 @@ import { httpServerMock, } from '../../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../../index.mock'; -import { Feature } from '../../../../../features/server'; +import { KibanaFeature } from '../../../../../features/server'; import { securityFeatureUsageServiceMock } from '../../../feature_usage/index.mock'; const application = 'kibana-.kibana'; @@ -83,7 +83,7 @@ const putRoleTest = ( ); mockRouteDefinitionParams.getFeatures.mockResolvedValue([ - new Feature({ + new KibanaFeature({ id: 'feature_1', name: 'feature 1', app: [], diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index d83cf92bcaa0d..cdedc9ac8a5eb 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -5,7 +5,7 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { Feature } from '../../../../../features/common'; +import { KibanaFeature } from '../../../../../features/common'; import { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../../errors'; @@ -16,7 +16,7 @@ import { } from './model'; const roleGrantsSubFeaturePrivileges = ( - features: Feature[], + features: KibanaFeature[], role: TypeOf> ) => { if (!role.kibana) { @@ -77,7 +77,7 @@ export function definePutRolesRoutes({ rawRoles[name] ? rawRoles[name].applications : [] ); - const [features] = await Promise.all([ + const [features] = await Promise.all([ getFeatures(), clusterClient .asScoped(request) diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index a3f046ae4f9e6..7880e95240ff0 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; import { HttpResources, IBasePath, @@ -42,7 +42,7 @@ export interface RouteDefinitionParams { authz: AuthorizationServiceSetup; session: PublicMethodsOf; license: SecurityLicense; - getFeatures: () => Promise; + getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; } diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 2cf072453a3c7..7ada34ff5ccac 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -222,15 +222,17 @@ function getMockCheckPrivilegesSuccess(actions: string | string[], namespaces?: return { hasAllRequested: true, username: USERNAME, - privileges: _namespaces - .map((resource) => - _actions.map((action) => ({ - resource, - privilege: action, - authorized: true, - })) - ) - .flat(), + privileges: { + kibana: _namespaces + .map((resource) => + _actions.map((action) => ({ + resource, + privilege: action, + authorized: true, + })) + ) + .flat(), + }, }; } @@ -246,15 +248,17 @@ function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: return { hasAllRequested: false, username: USERNAME, - privileges: _namespaces - .map((resource, idxa) => - _actions.map((action, idxb) => ({ - resource, - privilege: action, - authorized: idxa > 0 || idxb > 0, - })) - ) - .flat(), + privileges: { + kibana: _namespaces + .map((resource, idxa) => + _actions.map((action, idxb) => ({ + resource, + privilege: action, + authorized: idxa > 0 || idxb > 0, + })) + ) + .flat(), + }, }; } diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index bfa08a0116644..16e52c69f274f 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -19,7 +19,7 @@ import { } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; -import { CheckPrivilegesResponse } from '../authorization/check_privileges'; +import { CheckPrivilegesResponse } from '../authorization/types'; import { SpacesService } from '../plugin'; interface SecureSavedObjectsClientWrapperOptions { @@ -242,12 +242,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const { hasAllRequested, username, privileges } = result; const spaceIds = uniq( - privileges.map(({ resource }) => resource).filter((x) => x !== undefined) + privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined) ).sort() as string[]; const isAuthorized = (requiresAll && hasAllRequested) || - (!requiresAll && privileges.some(({ authorized }) => authorized)); + (!requiresAll && privileges.kibana.some(({ authorized }) => authorized)); if (isAuthorized) { this.auditLogger.savedObjectsAuthorizationSuccess( username, @@ -275,7 +275,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { - return privileges + return privileges.kibana .filter(({ authorized }) => !authorized) .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); } @@ -288,7 +288,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const action = this.actions.login; const checkPrivilegesResult = await this.checkPrivileges(action, namespaces); // check if the user can log into each namespace - const map = checkPrivilegesResult.privileges.reduce( + const map = checkPrivilegesResult.privileges.kibana.reduce( (acc: Record, { resource, authorized }) => { // there should never be a case where more than one privilege is returned for a given space // if there is, fail-safe (authorized + unauthorized = unauthorized) diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 1f4790a8981c9..d203c6dcc48c4 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -171,7 +171,7 @@ export class Plugin implements IPlugin public async setup( { http, getStartServices }: CoreSetup, - { licensing, security, cloud }: Dependencies + { licensing, features, security, cloud }: Dependencies ): Promise { const pluginConfig = await this.context.config .create() @@ -81,6 +81,19 @@ export class SnapshotRestoreServerPlugin implements Plugin } ); + features.registerElasticsearchFeature({ + id: PLUGIN.id, + management: { + data: [PLUGIN.id], + }, + privileges: [ + { + requiredClusterPrivileges: [...APP_REQUIRED_CLUSTER_PRIVILEGES], + ui: [], + }, + ], + }); + http.registerRouteHandlerContext('snapshotRestore', async (ctx, request) => { this.snapshotRestoreESClient = this.snapshotRestoreESClient ?? (await getCustomEsClient(getStartServices)); diff --git a/x-pack/plugins/snapshot_restore/server/types.ts b/x-pack/plugins/snapshot_restore/server/types.ts index 8cfcaec1a2cd1..eb51f086deacc 100644 --- a/x-pack/plugins/snapshot_restore/server/types.ts +++ b/x-pack/plugins/snapshot_restore/server/types.ts @@ -7,12 +7,14 @@ import { LegacyScopedClusterClient, IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { CloudSetup } from '../../cloud/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; import { wrapEsError } from './lib'; import { isEsError } from './shared_imports'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; security?: SecurityPluginSetup; cloud?: CloudSetup; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx index ad5ebe157cfb8..0eed6793ddbe0 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx @@ -10,9 +10,9 @@ import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Space } from '../../../../common/model/space'; import { SectionPanel } from '../section_panel'; import { EnabledFeatures } from './enabled_features'; -import { FeatureConfig } from '../../../../../features/public'; +import { KibanaFeatureConfig } from '../../../../../features/public'; -const features: FeatureConfig[] = [ +const features: KibanaFeatureConfig[] = [ { id: 'feature-1', name: 'Feature 1', diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx index 373e0b42aebe5..689bb610d5f38 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment, ReactNode } from 'react'; import { ApplicationStart } from 'kibana/public'; -import { FeatureConfig } from '../../../../../../plugins/features/public'; +import { KibanaFeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { getEnabledFeatures } from '../../lib/feature_utils'; import { SectionPanel } from '../section_panel'; @@ -17,7 +17,7 @@ import { FeatureTable } from './feature_table'; interface Props { space: Partial; - features: FeatureConfig[]; + features: KibanaFeatureConfig[]; securityEnabled: boolean; onChange: (space: Partial) => void; getUrlForApp: ApplicationStart['getUrlForApp']; diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index df07d128e497b..9265ca46e3a3a 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; -import { FeatureConfig } from '../../../../../../plugins/features/public'; +import { KibanaFeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { ToggleAllFeatures } from './toggle_all_features'; interface Props { space: Partial; - features: FeatureConfig[]; + features: KibanaFeatureConfig[]; onChange: (space: Partial) => void; } @@ -70,8 +70,8 @@ export class FeatureTable extends Component { defaultMessage: 'Feature', }), render: ( - feature: FeatureConfig, - _item: { feature: FeatureConfig; space: Props['space'] } + feature: KibanaFeatureConfig, + _item: { feature: KibanaFeatureConfig; space: Props['space'] } ) => { return ( diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index b573848f0c84a..f580720848875 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -16,7 +16,7 @@ import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; import { featuresPluginMock } from '../../../../features/public/mocks'; -import { Feature } from '../../../../features/public'; +import { KibanaFeature } from '../../../../features/public'; // To be resolved by EUI team. // https://github.com/elastic/eui/issues/3712 @@ -34,7 +34,7 @@ const space = { const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockResolvedValue([ - new Feature({ + new KibanaFeature({ id: 'feature-1', name: 'feature 1', icon: 'spacesApp', diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.tsx index e725310c41817..5338710b7c8a4 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { ApplicationStart, Capabilities, NotificationsStart, ScopedHistory } from 'src/core/public'; -import { Feature, FeaturesPluginStart } from '../../../../features/public'; +import { KibanaFeature, FeaturesPluginStart } from '../../../../features/public'; import { isReservedSpace } from '../../../common'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; @@ -46,7 +46,7 @@ interface Props { interface State { space: Partial; - features: Feature[]; + features: KibanaFeature[]; originalSpace?: Partial; showAlteringActiveSpaceDialog: boolean; isLoading: boolean; @@ -312,7 +312,7 @@ export class ManageSpacePage extends Component { } }; - private loadSpace = async (spaceId: string, featuresPromise: Promise) => { + private loadSpace = async (spaceId: string, featuresPromise: Promise) => { const { spacesManager, onLoadSpace } = this.props; try { diff --git a/x-pack/plugins/spaces/public/management/lib/feature_utils.test.ts b/x-pack/plugins/spaces/public/management/lib/feature_utils.test.ts index 20d419e5c90e4..212ffe96cdbf6 100644 --- a/x-pack/plugins/spaces/public/management/lib/feature_utils.test.ts +++ b/x-pack/plugins/spaces/public/management/lib/feature_utils.test.ts @@ -5,7 +5,7 @@ */ import { getEnabledFeatures } from './feature_utils'; -import { FeatureConfig } from '../../../../features/public'; +import { KibanaFeatureConfig } from '../../../../features/public'; const buildFeatures = () => [ @@ -25,7 +25,7 @@ const buildFeatures = () => id: 'feature4', name: 'feature 4', }, - ] as FeatureConfig[]; + ] as KibanaFeatureConfig[]; const buildSpace = (disabledFeatures = [] as string[]) => ({ id: 'space', diff --git a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts index 273ea7e60bc5e..c6f7031976a9b 100644 --- a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts +++ b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FeatureConfig } from '../../../../features/common'; +import { KibanaFeatureConfig } from '../../../../features/common'; import { Space } from '../..'; -export function getEnabledFeatures(features: FeatureConfig[], space: Partial) { +export function getEnabledFeatures(features: KibanaFeatureConfig[], space: Partial) { return features.filter((feature) => !(space.disabledFeatures || []).includes(feature.id)); } diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 36efc68749783..b40f34273d99f 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -21,7 +21,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ApplicationStart, Capabilities, NotificationsStart, ScopedHistory } from 'src/core/public'; -import { Feature, FeaturesPluginStart } from '../../../../features/public'; +import { KibanaFeature, FeaturesPluginStart } from '../../../../features/public'; import { isReservedSpace } from '../../../common'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { Space } from '../../../common/model/space'; @@ -46,7 +46,7 @@ interface Props { interface State { spaces: Space[]; - features: Feature[]; + features: KibanaFeature[]; loading: boolean; showConfirmDeleteModal: boolean; selectedSpace: Space | null; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 607570eedc787..fe4bdc865094f 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -13,7 +13,7 @@ import { SpacesGridPage } from './spaces_grid_page'; import { httpServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; import { notificationServiceMock } from 'src/core/public/mocks'; import { featuresPluginMock } from '../../../../features/public/mocks'; -import { Feature } from '../../../../features/public'; +import { KibanaFeature } from '../../../../features/public'; const spaces = [ { @@ -42,7 +42,7 @@ spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockResolvedValue([ - new Feature({ + new KibanaFeature({ id: 'feature-1', name: 'feature 1', icon: 'spacesApp', diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts index 8678bdceb70f9..b0b89afa79d5d 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts @@ -10,6 +10,9 @@ describe('Capabilities provider', () => { it('provides the expected capabilities', () => { expect(capabilitiesProvider()).toMatchInlineSnapshot(` Object { + "catalogue": Object { + "spaces": true, + }, "management": Object { "kibana": Object { "spaces": true, diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts index 5976aabfa66e8..1aaf2ad1df925 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts @@ -8,6 +8,9 @@ export const capabilitiesProvider = () => ({ spaces: { manage: true, }, + catalogue: { + spaces: true, + }, management: { kibana: { spaces: true, diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index c9ea1b44e723d..bf0b51b7e2503 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../plugins/features/server'; +import { KibanaFeature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; import { setupCapabilitiesSwitcher } from './capabilities_switcher'; import { Capabilities, CoreSetup } from 'src/core/server'; @@ -80,7 +80,7 @@ const features = ([ }, }, }, -] as unknown) as Feature[]; +] as unknown) as KibanaFeature[]; const buildCapabilities = () => Object.freeze({ @@ -121,7 +121,7 @@ const setup = (space: Space) => { const coreSetup = coreMock.createSetup(); const featuresStart = featuresPluginMock.createStart(); - featuresStart.getFeatures.mockReturnValue(features); + featuresStart.getKibanaFeatures.mockReturnValue(features); coreSetup.getStartServices.mockResolvedValue([ coreMock.createStart(), diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index e8d964b22010c..8b0b955c40d92 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -5,7 +5,7 @@ */ import _ from 'lodash'; import { Capabilities, CapabilitiesSwitcher, CoreSetup, Logger } from 'src/core/server'; -import { Feature } from '../../../../plugins/features/server'; +import { KibanaFeature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; import { SpacesServiceSetup } from '../spaces_service'; import { PluginsStart } from '../plugin'; @@ -28,7 +28,7 @@ export function setupCapabilitiesSwitcher( core.getStartServices(), ]); - const registeredFeatures = features.getFeatures(); + const registeredFeatures = features.getKibanaFeatures(); // try to retrieve capabilities for authenticated or "maybe authenticated" users return toggleCapabilities(registeredFeatures, capabilities, activeSpace); @@ -39,7 +39,11 @@ export function setupCapabilitiesSwitcher( }; } -function toggleCapabilities(features: Feature[], capabilities: Capabilities, activeSpace: Space) { +function toggleCapabilities( + features: KibanaFeature[], + capabilities: Capabilities, + activeSpace: Space +) { const clonedCapabilities = _.cloneDeep(capabilities); toggleDisabledFeatures(features, clonedCapabilities, activeSpace); @@ -48,7 +52,7 @@ function toggleCapabilities(features: Feature[], capabilities: Capabilities, act } function toggleDisabledFeatures( - features: Feature[], + features: KibanaFeature[], capabilities: Capabilities, activeSpace: Space ) { @@ -61,7 +65,7 @@ function toggleDisabledFeatures( } return [[...acc[0], feature], acc[1]]; }, - [[], []] as [Feature[], Feature[]] + [[], []] as [KibanaFeature[], KibanaFeature[]] ); const navLinks = capabilities.navLinks; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index dabdcf553edb4..fe1acd93570f6 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -25,7 +25,7 @@ import { SpacesService } from '../../spaces_service'; import { SpacesAuditLogger } from '../audit_logger'; import { convertSavedObjectToSpace } from '../../routes/lib'; import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor'; -import { Feature } from '../../../../features/server'; +import { KibanaFeature } from '../../../../features/server'; import { spacesConfig } from '../__fixtures__'; import { securityMock } from '../../../../security/server/mocks'; import { featuresPluginMock } from '../../../../features/server/mocks'; @@ -124,7 +124,7 @@ describe.skip('onPostAuthInterceptor', () => { const loggingMock = loggingSystemMock.create().asLoggerFactory().get('xpack', 'spaces'); const featuresPlugin = featuresPluginMock.createSetup(); - featuresPlugin.getFeatures.mockReturnValue(([ + featuresPlugin.getKibanaFeatures.mockReturnValue(([ { id: 'feature-1', name: 'feature 1', @@ -145,7 +145,7 @@ describe.skip('onPostAuthInterceptor', () => { name: 'feature 4', app: ['kibana'], }, - ] as unknown) as Feature[]); + ] as unknown) as KibanaFeature[]); const mockRepository = jest.fn().mockImplementation(() => { return { diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index 3d6084d37a384..e4ca0f8072f96 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -108,7 +108,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ if (appId !== 'kibana' && space && space.disabledFeatures.length > 0) { log.debug(`Verifying application is available: "${appId}"`); - const allFeatures = features.getFeatures(); + const allFeatures = features.getKibanaFeatures(); const isRegisteredApp = allFeatures.some((feature) => feature.app.includes(appId)); if (isRegisteredApp) { diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 90ce2b01bfd20..1090b029069d2 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -260,10 +260,12 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - privileges: [ - { resource: savedObjects[0].id, privilege, authorized: false }, - { resource: savedObjects[1].id, privilege, authorized: false }, - ], + privileges: { + kibana: [ + { resource: savedObjects[0].id, privilege, authorized: false }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], + }, }); const maxSpaces = 1234; const mockConfig = createMockConfig({ @@ -298,7 +300,7 @@ describe('#getAll', () => { expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( savedObjects.map((savedObject) => savedObject.id), - [privilege] + { kibana: [privilege] } ); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith( username, @@ -318,10 +320,12 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - privileges: [ - { resource: savedObjects[0].id, privilege, authorized: true }, - { resource: savedObjects[1].id, privilege, authorized: false }, - ], + privileges: { + kibana: [ + { resource: savedObjects[0].id, privilege, authorized: true }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], + }, }); const mockInternalRepository = { find: jest.fn().mockReturnValue({ @@ -357,7 +361,7 @@ describe('#getAll', () => { expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( savedObjects.map((savedObject) => savedObject.id), - [privilege] + { kibana: [privilege] } ); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( @@ -451,9 +455,9 @@ describe('#canEnumerateSpaces', () => { expect(canEnumerateSpaces).toEqual(false); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); @@ -486,9 +490,9 @@ describe('#canEnumerateSpaces', () => { expect(canEnumerateSpaces).toEqual(true); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); @@ -603,7 +607,9 @@ describe('#get', () => { await expect(client.get(id)).rejects.toThrowErrorMatchingSnapshot(); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, mockAuthorization.actions.login); + expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, { + kibana: mockAuthorization.actions.login, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'get', [ id, ]); @@ -641,7 +647,9 @@ describe('#get', () => { expect(space).toEqual(expectedSpace); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, mockAuthorization.actions.login); + expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, { + kibana: mockAuthorization.actions.login, + }); expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [ @@ -886,9 +894,9 @@ describe('#create', () => { expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'create'); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); }); @@ -939,9 +947,9 @@ describe('#create', () => { }); expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'create'); }); @@ -989,9 +997,9 @@ describe('#create', () => { expect(mockInternalRepository.create).not.toHaveBeenCalled(); expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'create'); }); @@ -1128,9 +1136,9 @@ describe('#update', () => { expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'update'); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); }); @@ -1167,9 +1175,9 @@ describe('#update', () => { expect(actualSpace).toEqual(expectedReturnedSpace); expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockInternalRepository.update).toHaveBeenCalledWith('space', id, attributes); expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); @@ -1353,9 +1361,9 @@ describe('#delete', () => { expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'delete'); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); }); @@ -1389,9 +1397,9 @@ describe('#delete', () => { expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete'); @@ -1429,9 +1437,9 @@ describe('#delete', () => { expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( - mockAuthorization.actions.space.manage - ); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: mockAuthorization.actions.space.manage, + }); expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); expect(mockInternalRepository.delete).toHaveBeenCalledWith('space', id); expect(mockInternalRepository.deleteByNamespace).toHaveBeenCalledWith(id); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index b1d6e3200ab3a..acb00a87bf7d9 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -50,9 +50,9 @@ export class SpacesClient { public async canEnumerateSpaces(): Promise { if (this.useRbac()) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { hasAllRequested } = await checkPrivileges.globally( - this.authorization!.actions.space.manage - ); + const { hasAllRequested } = await checkPrivileges.globally({ + kibana: this.authorization!.actions.space.manage, + }); this.debugLogger(`SpacesClient.canEnumerateSpaces, using RBAC. Result: ${hasAllRequested}`); return hasAllRequested; } @@ -87,9 +87,11 @@ export class SpacesClient { const privilege = privilegeFactory(this.authorization!); - const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, privilege); + const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, { + kibana: privilege, + }); - const authorized = privileges.filter((x) => x.authorized).map((x) => x.resource); + const authorized = privileges.kibana.filter((x) => x.authorized).map((x) => x.resource); this.debugLogger( `SpacesClient.getAll(), authorized for ${ @@ -234,7 +236,7 @@ export class SpacesClient { private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.globally(action); + const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action }); if (hasAllRequested) { this.auditLogger.spacesAuthorizationSuccess(username, method); @@ -252,7 +254,9 @@ export class SpacesClient { forbiddenMessage: string ) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, action); + const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, { + kibana: action, + }); if (hasAllRequested) { this.auditLogger.spacesAuthorizationSuccess(username, method, [spaceId]); diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index a82f2370cc124..b650a114ed978 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -8,14 +8,14 @@ import { CoreSetup } from 'src/core/server'; import { coreMock } from 'src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; -import { Plugin, PluginsSetup } from './plugin'; +import { Plugin, PluginsStart } from './plugin'; import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collection/server/mocks'; describe('Spaces Plugin', () => { describe('#setup', () => { it('can setup with all optional plugins disabled, exposing the expected contract', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); - const core = coreMock.createSetup() as CoreSetup; + const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); const licensing = licensingMock.createSetup(); @@ -38,7 +38,7 @@ describe('Spaces Plugin', () => { it('registers the capabilities provider and switcher', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); - const core = coreMock.createSetup() as CoreSetup; + const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); const licensing = licensingMock.createSetup(); @@ -52,7 +52,7 @@ describe('Spaces Plugin', () => { it('registers the usage collector', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); - const core = coreMock.createSetup() as CoreSetup; + const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); const licensing = licensingMock.createSetup(); @@ -67,7 +67,7 @@ describe('Spaces Plugin', () => { it('registers the "space" saved object type and client wrapper', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); - const core = coreMock.createSetup() as CoreSetup; + const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); const licensing = licensingMock.createSetup(); diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts index 57ec688ab70e8..fddd7f92b7f27 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts @@ -7,18 +7,18 @@ import { getSpacesUsageCollector, UsageStats } from './spaces_usage_collector'; import * as Rx from 'rxjs'; import { PluginsSetup } from '../plugin'; -import { Feature } from '../../../features/server'; +import { KibanaFeature } from '../../../features/server'; import { ILicense, LicensingPluginSetup } from '../../../licensing/server'; import { pluginInitializerContextConfigMock } from 'src/core/server/mocks'; interface SetupOpts { license?: Partial; - features?: Feature[]; + features?: KibanaFeature[]; } function setup({ license = { isAvailable: true }, - features = [{ id: 'feature1' } as Feature, { id: 'feature2' } as Feature], + features = [{ id: 'feature1' } as KibanaFeature, { id: 'feature2' } as KibanaFeature], }: SetupOpts = {}) { class MockUsageCollector { private fetch: any; @@ -37,7 +37,7 @@ function setup({ } as LicensingPluginSetup; const featuresSetup = ({ - getFeatures: jest.fn().mockReturnValue(features), + getKibanaFeatures: jest.fn().mockReturnValue(features), } as unknown) as PluginsSetup['features']; return { diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts index 3ea4693d9e9d7..36d46c3d01baf 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts @@ -46,7 +46,7 @@ async function getSpacesUsage( return null; } - const knownFeatureIds = features.getFeatures().map((feature) => feature.id); + const knownFeatureIds = features.getKibanaFeatures().map((feature) => feature.id); let resp: SpacesAggregationResponse | undefined; try { diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index d7e7a7fabba4f..2efe0bb25bc68 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -7,7 +7,8 @@ "data", "home", "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts index 79e9be239c798..988750f70efe0 100644 --- a/x-pack/plugins/transform/server/plugin.ts +++ b/x-pack/plugins/transform/server/plugin.ts @@ -58,7 +58,7 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { this.license = new License(); } - setup({ http, getStartServices }: CoreSetup, { licensing }: Dependencies): {} { + setup({ http, getStartServices }: CoreSetup, { licensing, features }: Dependencies): {} { const router = http.createRouter(); this.license.setup( @@ -75,6 +75,20 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { } ); + features.registerElasticsearchFeature({ + id: PLUGIN.id, + management: { + data: [PLUGIN.id], + }, + catalogue: [PLUGIN.id], + privileges: [ + { + requiredClusterPrivileges: ['monitor_transform'], + ui: [], + }, + ], + }); + this.apiRoutes.setup({ router, license: this.license, diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts index 5fcc23a6d9f48..c3d7434f14f45 100644 --- a/x-pack/plugins/transform/server/types.ts +++ b/x-pack/plugins/transform/server/types.ts @@ -6,10 +6,12 @@ import { IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 273036a653aeb..c4c6f23611f2b 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "configPath": ["xpack", "upgrade_assistant"], - "requiredPlugins": ["management", "licensing"], + "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index 0cdf1ca05feac..9ef0f250da8ef 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -16,6 +16,7 @@ import { } from '../../../../src/core/server'; import { CloudSetup } from '../../cloud/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { CredentialStore, credentialStoreFactory } from './lib/reindexing/credential_store'; @@ -32,6 +33,7 @@ import { RouteDependencies } from './types'; interface PluginsSetup { usageCollection: UsageCollectionSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; cloud?: CloudSetup; } @@ -60,13 +62,26 @@ export class UpgradeAssistantServerPlugin implements Plugin { setup( { http, getStartServices, capabilities, savedObjects }: CoreSetup, - { usageCollection, cloud, licensing }: PluginsSetup + { usageCollection, cloud, features, licensing }: PluginsSetup ) { this.licensing = licensing; savedObjects.registerType(reindexOperationSavedObjectType); savedObjects.registerType(telemetrySavedObjectType); + features.registerElasticsearchFeature({ + id: 'upgrade_assistant', + management: { + stack: ['upgrade_assistant'], + }, + privileges: [ + { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + ], + }); + const router = http.createRouter(); const dependencies: RouteDependencies = { diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 76359a3b60a6a..5c3211eff3b4e 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -27,7 +27,7 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor const { features } = plugins; const libs = compose(server); - features.registerFeature({ + features.registerKibanaFeature({ id: PLUGIN.ID, name: PLUGIN.NAME, order: 1000, diff --git a/x-pack/plugins/watcher/kibana.json b/x-pack/plugins/watcher/kibana.json index ba6a9bfa5e194..695686715cb6a 100644 --- a/x-pack/plugins/watcher/kibana.json +++ b/x-pack/plugins/watcher/kibana.json @@ -7,7 +7,8 @@ "licensing", "management", "charts", - "data" + "data", + "features" ], "server": true, "ui": true, diff --git a/x-pack/plugins/watcher/server/plugin.ts b/x-pack/plugins/watcher/server/plugin.ts index 70c4f980580e8..9ff46283a72a6 100644 --- a/x-pack/plugins/watcher/server/plugin.ts +++ b/x-pack/plugins/watcher/server/plugin.ts @@ -18,7 +18,7 @@ import { Plugin, PluginInitializerContext, } from 'kibana/server'; -import { PLUGIN } from '../common/constants'; +import { PLUGIN, INDEX_NAMES } from '../common/constants'; import { Dependencies, LicenseStatus, RouteDependencies } from './types'; import { registerSettingsRoutes } from './routes/api/settings'; @@ -52,13 +52,39 @@ export class WatcherServerPlugin implements Plugin { this.log = ctx.logger.get(); } - async setup({ http, getStartServices }: CoreSetup, { licensing }: Dependencies) { + async setup({ http, getStartServices }: CoreSetup, { licensing, features }: Dependencies) { const router = http.createRouter(); const routeDependencies: RouteDependencies = { router, getLicenseStatus: () => this.licenseStatus, }; + features.registerElasticsearchFeature({ + id: 'watcher', + management: { + insightsAndAlerting: ['watcher'], + }, + catalogue: ['watcher'], + privileges: [ + { + requiredClusterPrivileges: ['manage_watcher'], + requiredIndexPrivileges: { + [INDEX_NAMES.WATCHES]: ['read'], + [INDEX_NAMES.WATCHER_HISTORY]: ['read'], + }, + ui: [], + }, + { + requiredClusterPrivileges: ['monitor_watcher'], + requiredIndexPrivileges: { + [INDEX_NAMES.WATCHES]: ['read'], + [INDEX_NAMES.WATCHER_HISTORY]: ['read'], + }, + ui: [], + }, + ], + }); + http.registerRouteHandlerContext('watcher', async (ctx, request) => { this.watcherESClient = this.watcherESClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/watcher/server/types.ts b/x-pack/plugins/watcher/server/types.ts index dd941054114a8..167dcb3ab64c3 100644 --- a/x-pack/plugins/watcher/server/types.ts +++ b/x-pack/plugins/watcher/server/types.ts @@ -5,12 +5,14 @@ */ import { IRouter } from 'kibana/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/server/xpack_main'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface ServerShim { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 88f0f02794c9b..68ff3dad9ae86 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -72,7 +72,7 @@ export class FixturePlugin implements Plugin { public setup(core: CoreSetup, { features, actions, alerts }: FixtureSetupDeps) { - features.registerFeature({ + features.registerKibanaFeature({ id: 'alertsFixture', name: 'Alerts', app: ['alerts', 'kibana'], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts index e297733fb47eb..e1ef1255c6e13 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts @@ -23,7 +23,7 @@ export interface FixtureStartDeps { export class FixturePlugin implements Plugin { public setup(core: CoreSetup, { features, alerts }: FixtureSetupDeps) { - features.registerFeature({ + features.registerKibanaFeature({ id: 'alertsRestrictedFixture', name: 'AlertRestricted', app: ['alerts', 'kibana'], diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 9c44bfeb810fa..37809a3b7aeb7 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { Feature } from '../../../../../plugins/features/server'; +import { KibanaFeature } from '../../../../../plugins/features/server'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { @@ -90,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.be.an(Array); - const featureIds = body.map((b: Feature) => b.id); + const featureIds = body.map((b: KibanaFeature) => b.id); expect(featureIds.sort()).to.eql( [ 'discover', diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index 5b0d28bf09508..ac4a1298e28b9 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -10,7 +10,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); - const config = getService('config'); const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); @@ -174,20 +173,18 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await security.user.delete('no_advanced_settings_privileges_user'); }); - it('shows Management navlink', async () => { + it('does not show Management navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Discover', 'Stack Management']); + expect(navLinks).to.eql(['Discover']); }); - it(`does not allow navigation to advanced settings; redirects to management home`, async () => { + it(`does not allow navigation to advanced settings; shows "not found" error`, async () => { await PageObjects.common.navigateToUrl('management', 'kibana/settings', { ensureCurrentUrl: false, shouldLoginIfPrompted: false, shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('managementHome', { - timeout: config.get('timeouts.waitFor'), - }); + await testSubjects.existOrFail('appNotFoundPageContent'); }); }); }); diff --git a/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts new file mode 100644 index 0000000000000..d3d2846082854 --- /dev/null +++ b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Security" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with manage_security', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'manage_security'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Security" section with API Keys', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'security', + sectionLinks: ['users', 'roles', 'api_keys', 'role_mappings'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/api_keys/feature_controls/index.ts b/x-pack/test/functional/apps/api_keys/feature_controls/index.ts new file mode 100644 index 0000000000000..169b5c7fb0a73 --- /dev/null +++ b/x-pack/test/functional/apps/api_keys/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./api_keys_security')); + }); +} diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 0c4097a1d5c4e..39d8449218ffa 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -24,10 +24,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); // https://www.elastic.co/guide/en/kibana/7.6/api-keys.html#api-keys-security-privileges - it('Shows required privileges ', async () => { - log.debug('Checking for required privileges method section header'); - const message = await pageObjects.apiKeys.apiKeysPermissionDeniedMessage(); - expect(message).to.be('You need permission to manage API keys'); + it('Hides management link if user is not authorized', async () => { + await testSubjects.missingOrFail('apiKeys'); }); it('Loads the app', async () => { diff --git a/x-pack/test/functional/apps/api_keys/index.ts b/x-pack/test/functional/apps/api_keys/index.ts index 703aae04140f2..7a17430dc8f6c 100644 --- a/x-pack/test/functional/apps/api_keys/index.ts +++ b/x-pack/test/functional/apps/api_keys/index.ts @@ -10,5 +10,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { describe('API Keys app', function () { this.tags(['ciGroup7']); loadTestFile(require.resolve('./home_page')); + loadTestFile(require.resolve('./feature_controls')); }); }; diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index e9fa4ccf8e48b..5a8fb207d5062 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts @@ -66,7 +66,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows canvas navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Canvas', 'Stack Management']); + expect(navLinks).to.eql(['Canvas']); }); it(`landing page shows "Create new workpad" button`, async () => { @@ -142,7 +142,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows canvas navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Canvas', 'Stack Management']); + expect(navLinks).to.eql(['Canvas']); }); it(`landing page shows disabled "Create new workpad" button`, async () => { diff --git a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts new file mode 100644 index 0000000000000..6b4b9c61151ba --- /dev/null +++ b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Data" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with ccr_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'ccr_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Data" section with CCR', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[1]).to.eql({ + sectionId: 'data', + sectionLinks: [ + 'index_management', + 'index_lifecycle_management', + 'snapshot_restore', + 'rollup_jobs', + 'transform', + 'cross_cluster_replication', + 'remote_clusters', + ], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/index.ts b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/index.ts new file mode 100644 index 0000000000000..e7be2cb48ce3e --- /dev/null +++ b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./ccr_security')); + }); +} diff --git a/x-pack/test/functional/apps/cross_cluster_replication/index.ts b/x-pack/test/functional/apps/cross_cluster_replication/index.ts index 5db6103307af9..0e54c0d1c0d15 100644 --- a/x-pack/test/functional/apps/cross_cluster_replication/index.ts +++ b/x-pack/test/functional/apps/cross_cluster_replication/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('Cross Cluster Replication app', function () { this.tags(['ciGroup4', 'skipCloud']); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./home_page')); }); }; diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index 505e35907bd80..46dc0316a5d6b 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -81,9 +81,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await security.user.delete('global_dashboard_all_user'); }); - it('shows dashboard navlink', async () => { + it('only shows the dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.contain('Dashboard'); + expect(navLinks.map((link) => link.text)).to.eql(['Dashboard']); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -287,7 +287,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows dashboard navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.contain('Dashboard'); + expect(navLinks).to.eql(['Dashboard']); }); it(`landing page doesn't show "Create new Dashboard" button`, async () => { @@ -415,7 +415,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows dashboard navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.contain('Dashboard'); + expect(navLinks).to.eql(['Dashboard']); }); it(`landing page doesn't show "Create new Dashboard" button`, async () => { diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts index 803ff6399a035..807ba6ded88a2 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts @@ -63,7 +63,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows Dev Tools navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Dev Tools', 'Stack Management']); + expect(navLinks.map((link) => link.text)).to.eql(['Dev Tools']); }); describe('console', () => { @@ -144,7 +144,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it(`shows 'Dev Tools' navlink`, async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Dev Tools', 'Stack Management']); + expect(navLinks).to.eql(['Dev Tools']); }); describe('console', () => { diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 8be4349762808..d94451d023ec0 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -82,7 +82,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Discover', 'Stack Management']); + expect(navLinks.map((link) => link.text)).to.eql(['Discover']); }); it('shows save button', async () => { @@ -184,7 +184,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Discover', 'Stack Management']); + expect(navLinks).to.eql(['Discover']); }); it(`doesn't show save button`, async () => { @@ -275,7 +275,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Discover', 'Stack Management']); + expect(navLinks).to.eql(['Discover']); }); it(`doesn't show save button`, async () => { diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts index 9121028c14404..3b4a1fbdbe0d8 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts @@ -64,7 +64,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows graph navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Graph', 'Stack Management']); + expect(navLinks.map((link) => link.text)).to.eql(['Graph']); }); it('landing page shows "Create new graph" button', async () => { @@ -127,7 +127,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows graph navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Graph', 'Stack Management']); + expect(navLinks).to.eql(['Graph']); }); it('does not show a "Create new Workspace" button', async () => { diff --git a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts new file mode 100644 index 0000000000000..4cb0d3077aaa4 --- /dev/null +++ b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Data" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with manage_ilm', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'manage_ilm'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Data" section with ILM', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'data', + sectionLinks: ['index_lifecycle_management'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/index.ts b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/index.ts new file mode 100644 index 0000000000000..0bb6476f36687 --- /dev/null +++ b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./ilm_security')); + }); +} diff --git a/x-pack/test/functional/apps/index_lifecycle_management/index.ts b/x-pack/test/functional/apps/index_lifecycle_management/index.ts index f535710814ab2..157fb62b7a84d 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/index.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('Index Lifecycle Management app', function () { this.tags('ciGroup7'); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./home_page')); }); }; diff --git a/x-pack/test/functional/apps/index_management/feature_controls/index.ts b/x-pack/test/functional/apps/index_management/feature_controls/index.ts new file mode 100644 index 0000000000000..85398a73eceff --- /dev/null +++ b/x-pack/test/functional/apps/index_management/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./index_management_security')); + }); +} diff --git a/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts new file mode 100644 index 0000000000000..2019751d9101c --- /dev/null +++ b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Data" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with index_management_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'index_management_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Data" section with index management', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'data', + sectionLinks: ['index_management', 'transform'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/index_management/index.ts b/x-pack/test/functional/apps/index_management/index.ts index a9bb44d002334..97b23cbf82c31 100644 --- a/x-pack/test/functional/apps/index_management/index.ts +++ b/x-pack/test/functional/apps/index_management/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('Index Management app', function () { this.tags('ciGroup3'); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./home_page')); }); }; diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index cedd96f147c2b..4873a11d75eaa 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -10,7 +10,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); - const config = getService('config'); const PageObjects = getPageObjects(['common', 'settings', 'security']); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); @@ -175,28 +174,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await security.user.delete('no_index_patterns_privileges_user'); }); - it('shows Management navlink', async () => { + it('does not show Management navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Discover', 'Stack Management']); + expect(navLinks).to.eql(['Discover']); }); it(`doesn't show Index Patterns in management side-nav`, async () => { - await PageObjects.settings.navigateTo(); - await testSubjects.existOrFail('managementHome', { - timeout: config.get('timeouts.waitFor'), - }); - await testSubjects.missingOrFail('indexPatterns'); - }); - - it(`does not allow navigation to Index Patterns; redirects to management home`, async () => { - await PageObjects.common.navigateToUrl('management', 'kibana/indexPatterns', { + await PageObjects.common.navigateToActualUrl('management', '', { ensureCurrentUrl: false, shouldLoginIfPrompted: false, - shouldUseHashForSubUrl: false, - }); - await testSubjects.existOrFail('managementHome', { - timeout: config.get('timeouts.waitFor'), }); + await testSubjects.existOrFail('~appNotFoundPageContent'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index 64154ff6cf3f7..552e948f56a9b 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -58,7 +58,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows logs navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Logs', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Logs']); }); describe('logs landing page without data', () => { @@ -121,7 +121,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows logs navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Logs', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Logs']); }); describe('logs landing page without data', () => { diff --git a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/index.ts b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/index.ts new file mode 100644 index 0000000000000..fbaf7648646b8 --- /dev/null +++ b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./ingest_pipelines_security')); + }); +} diff --git a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts new file mode 100644 index 0000000000000..bf703a8f60dc2 --- /dev/null +++ b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Ingest" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with ingest_pipelines_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'ingest_pipelines_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Ingest" section with ingest pipelines', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'ingest', + sectionLinks: ['ingest_pipelines'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ingest_pipelines/index.ts b/x-pack/test/functional/apps/ingest_pipelines/index.ts index 8d2b9ee1dcb69..2a4781c5e216d 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/index.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('Ingest pipelines app', function () { this.tags('ciGroup3'); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ingest_pipelines')); }); }; diff --git a/x-pack/test/functional/apps/license_management/feature_controls/index.ts b/x-pack/test/functional/apps/license_management/feature_controls/index.ts new file mode 100644 index 0000000000000..5c7c04d4ccde1 --- /dev/null +++ b/x-pack/test/functional/apps/license_management/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./license_management_security')); + }); +} diff --git a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts new file mode 100644 index 0000000000000..59fc287c6cf2e --- /dev/null +++ b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Stack" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with license_management_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Stack" section with License Management', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[2]).to.eql({ + sectionId: 'stack', + sectionLinks: ['license_management', 'upgrade_assistant'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/license_management/index.ts b/x-pack/test/functional/apps/license_management/index.ts index 6d01b1bb098f0..0b090223c18fe 100644 --- a/x-pack/test/functional/apps/license_management/index.ts +++ b/x-pack/test/functional/apps/license_management/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('License app', function () { this.tags('ciGroup7'); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./home_page')); }); }; diff --git a/x-pack/test/functional/apps/logstash/feature_controls/index.ts b/x-pack/test/functional/apps/logstash/feature_controls/index.ts new file mode 100644 index 0000000000000..d3cc7fae94d98 --- /dev/null +++ b/x-pack/test/functional/apps/logstash/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./logstash_security')); + }); +} diff --git a/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts new file mode 100644 index 0000000000000..8e2609e3b7e85 --- /dev/null +++ b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Ingest" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with logstash_read_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'logstash_read_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Ingest" section with Logstash Pipelines', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'ingest', + sectionLinks: ['pipelines'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/logstash/index.js b/x-pack/test/functional/apps/logstash/index.js index 515674577fb52..3258d948cedfc 100644 --- a/x-pack/test/functional/apps/logstash/index.js +++ b/x-pack/test/functional/apps/logstash/index.js @@ -8,6 +8,7 @@ export default function ({ loadTestFile }) { describe('logstash', function () { this.tags(['ciGroup2']); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./pipeline_list')); loadTestFile(require.resolve('./pipeline_create')); }); diff --git a/x-pack/test/functional/apps/management/feature_controls/index.ts b/x-pack/test/functional/apps/management/feature_controls/index.ts new file mode 100644 index 0000000000000..8b8226da7dc3c --- /dev/null +++ b/x-pack/test/functional/apps/management/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./management_security')); + }); +} diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts new file mode 100644 index 0000000000000..cf1a83ca49686 --- /dev/null +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + const testSubjects = getService('testSubjects'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('no management privileges', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should not show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.eql(['Dashboard']); + }); + + it('should render the "application not found" view when navigating to management directly', async () => { + await PageObjects.common.navigateToApp('management'); + expect(await testSubjects.exists('appNotFoundPageContent')).to.eql(true); + }); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should only render management entries controllable via Kibana privileges', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(2); + expect(sections[0]).to.eql({ + sectionId: 'insightsAndAlerting', + sectionLinks: ['triggersActions'], + }); + expect(sections[1]).to.eql({ + sectionId: 'kibana', + sectionLinks: ['indexPatterns', 'objects', 'spaces', 'settings'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/management/index.js b/x-pack/test/functional/apps/management/index.ts similarity index 67% rename from x-pack/test/functional/apps/management/index.js rename to x-pack/test/functional/apps/management/index.ts index 19c68a2da9d9b..7a461c9963be9 100644 --- a/x-pack/test/functional/apps/management/index.js +++ b/x-pack/test/functional/apps/management/index.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { describe('management', function () { this.tags(['ciGroup2']); loadTestFile(require.resolve('./create_index_pattern_wizard')); + loadTestFile(require.resolve('./feature_controls')); }); } diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index ae9b0f095fc44..e32f14200ad80 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -67,7 +67,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows maps navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Maps', 'Stack Management']); + expect(navLinks).to.eql(['Maps']); }); it(`allows a map to be created`, async () => { @@ -170,7 +170,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows Maps navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Maps', 'Stack Management']); + expect(navLinks).to.eql(['Maps']); }); it(`does not show create new button`, async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts index 6fd78458a6ce5..ab67e567e67ac 100644 --- a/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts @@ -55,16 +55,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should not allow to access the Stack Management ML page', async () => { await ml.testExecution.logTestStep( - 'should load the stack management with the ML menu item being present' + 'should load the stack management with the ML menu item being absent' ); - await ml.navigation.navigateToStackManagement(); - - await ml.testExecution.logTestStep( - 'should display the access denied page in stack management' - ); - await ml.navigation.navigateToStackManagementJobsListPage({ - expectAccessDenied: true, - }); + await ml.navigation.navigateToStackManagement({ expectMlLink: false }); }); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index a358e57f792c7..cb964995511ef 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -408,16 +408,9 @@ export default function ({ getService }: FtrProviderContext) { it('should display elements on Stack Management ML page correctly', async () => { await ml.testExecution.logTestStep( - 'should load the stack management with the ML menu item being present' + 'should load the stack management with the ML menu item being absent' ); - await ml.navigation.navigateToStackManagement(); - - await ml.testExecution.logTestStep( - 'should display the access denied page in stack management' - ); - await ml.navigation.navigateToStackManagementJobsListPage({ - expectAccessDenied: true, - }); + await ml.navigation.navigateToStackManagement({ expectMlLink: false }); }); }); } diff --git a/x-pack/test/functional/apps/remote_clusters/feature_controls/index.ts b/x-pack/test/functional/apps/remote_clusters/feature_controls/index.ts new file mode 100644 index 0000000000000..bfcaef629dc42 --- /dev/null +++ b/x-pack/test/functional/apps/remote_clusters/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./remote_clusters_security')); + }); +} diff --git a/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts new file mode 100644 index 0000000000000..b1edc74607161 --- /dev/null +++ b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Stack" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with license_management_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Data" section with Remote Clusters', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[1]).to.eql({ + sectionId: 'data', + sectionLinks: [ + 'index_management', + 'index_lifecycle_management', + 'snapshot_restore', + 'rollup_jobs', + 'transform', + 'remote_clusters', + ], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/remote_clusters/index.ts b/x-pack/test/functional/apps/remote_clusters/index.ts index d91d413e2b7af..0839c2f22af47 100644 --- a/x-pack/test/functional/apps/remote_clusters/index.ts +++ b/x-pack/test/functional/apps/remote_clusters/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('Remote Clusters app', function () { this.tags(['ciGroup4', 'skipCloud']); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./home_page')); }); }; diff --git a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts index 28b8153ea4c2b..02b2ec4d4c681 100644 --- a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts +++ b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts @@ -10,14 +10,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const security = getService('security'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects([ - 'common', - 'settings', - 'security', - 'error', - 'header', - 'savedObjects', - ]); + const PageObjects = getPageObjects(['common', 'settings', 'security', 'error', 'savedObjects']); let version: string = ''; describe('feature controls saved objects management', () => { @@ -310,12 +303,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('listing', () => { - it(`doesn't display management section`, async () => { - await PageObjects.settings.navigateTo(); - await testSubjects.existOrFail('managementHome'); // this ensures we've gotten to the management page - await testSubjects.missingOrFail('objects'); - }); - it(`can't navigate to listing page`, async () => { await PageObjects.common.navigateToUrl('management', 'kibana/objects', { ensureCurrentUrl: false, @@ -323,7 +310,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('managementHome'); + await testSubjects.existOrFail('appNotFoundPageContent'); }); }); @@ -338,8 +325,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, } ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('managementHome'); + await testSubjects.existOrFail('appNotFoundPageContent'); }); }); }); diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index 2054a7b0b0038..c547657bf880a 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -21,7 +21,6 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); - const retry = getService('retry'); describe('secure roles and permissions', function () { before(async () => { @@ -74,12 +73,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.security.login('Rashmi', 'changeme'); }); - it('Kibana User navigating to Management gets permission denied', async function () { + it('Kibana User does not have link to user management', async function () { await PageObjects.settings.navigateTo(); - await PageObjects.security.clickElasticsearchUsers(); - await retry.tryForTime(2000, async () => { - await testSubjects.find('permissionDeniedMessage'); - }); + await testSubjects.missingOrFail('users'); }); it('Kibana User navigating to Discover and trying to generate CSV gets - Authorization Error ', async function () { diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts index a3ade23f5c178..d705140954de4 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts @@ -60,7 +60,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows timelion navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Timelion', 'Stack Management']); + expect(navLinks).to.eql(['Timelion']); }); it(`allows a timelion sheet to be created`, async () => { @@ -112,7 +112,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows timelion navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Timelion', 'Stack Management']); + expect(navLinks).to.eql(['Timelion']); }); it(`does not allow a timelion sheet to be created`, async () => { diff --git a/x-pack/test/functional/apps/transform/feature_controls/index.ts b/x-pack/test/functional/apps/transform/feature_controls/index.ts new file mode 100644 index 0000000000000..794e6f516d982 --- /dev/null +++ b/x-pack/test/functional/apps/transform/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./transform_security')); + }); +} diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts new file mode 100644 index 0000000000000..5d7d8ec3c307e --- /dev/null +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.security.forceLogout(); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Stack" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with transform_user', () => { + before(async () => { + await security.testUser.setRoles(['global_dashboard_all', 'transform_user'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Data" section with Transform', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'data', + sectionLinks: ['transform'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index a01f3fa5d53a5..2837ddb7333e6 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -37,5 +37,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./creation_saved_search')); loadTestFile(require.resolve('./cloning')); loadTestFile(require.resolve('./editing')); + loadTestFile(require.resolve('./feature_controls')); }); } diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/index.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/index.ts new file mode 100644 index 0000000000000..f1c73e39fbc3e --- /dev/null +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('feature controls', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./upgrade_assistant_security')); + }); +} diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts new file mode 100644 index 0000000000000..1f541dbe03537 --- /dev/null +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'settings', 'security']); + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('security', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('home'); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('global all privileges (aka kibana_admin)', () => { + before(async () => { + await security.testUser.setRoles(['kibana_admin'], true); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should not render the "Stack" section', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = (await managementMenu.getSections()).map((section) => section.sectionId); + expect(sections).to.eql(['insightsAndAlerting', 'kibana']); + }); + }); + + describe('global dashboard all with global_upgrade_assistant_role', () => { + before(async () => { + await security.testUser.setRoles( + ['global_dashboard_all', 'global_upgrade_assistant_role'], + true + ); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show the Stack Management nav link', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + }); + + it('should render the "Stack" section with Upgrde Assistant', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[2]).to.eql({ + sectionId: 'stack', + sectionLinks: ['license_management', 'upgrade_assistant'], + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/upgrade_assistant/index.ts b/x-pack/test/functional/apps/upgrade_assistant/index.ts index 0e6c52f0812ee..131cb6a249c78 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/index.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/index.ts @@ -9,6 +9,7 @@ export default function upgradeCheckup({ loadTestFile }: FtrProviderContext) { describe('Upgrade checkup ', function upgradeAssistantTestSuite() { this.tags('ciGroup4'); + loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./upgrade_assistant')); }); } diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index 49435df4f1c2a..ca84a8e561164 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -79,7 +79,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Visualize', 'Stack Management']); + expect(navLinks).to.eql(['Visualize']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -210,7 +210,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Visualize', 'Stack Management']); + expect(navLinks).to.eql(['Visualize']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -325,7 +325,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Visualize', 'Stack Management']); + expect(navLinks).to.eql(['Visualize']); }); it(`landing page shows "Create new Visualization" button`, async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 16e2cd1559fce..d1164c8c85362 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -266,6 +266,16 @@ export default async function ({ readConfigFile }) { }, ], }, + global_dashboard_all: { + kibana: [ + { + feature: { + dashboard: ['all'], + }, + spaces: ['*'], + }, + ], + }, global_maps_all: { kibana: [ { @@ -352,6 +362,65 @@ export default async function ({ readConfigFile }) { }, ], }, + + manage_security: { + elasticsearch: { + cluster: ['manage_security'], + }, + }, + + ccr_user: { + elasticsearch: { + cluster: ['manage', 'manage_ccr'], + }, + }, + + manage_ilm: { + elasticsearch: { + cluster: ['manage_ilm'], + }, + }, + + index_management_user: { + elasticsearch: { + cluster: ['monitor', 'manage_index_templates'], + indices: [ + { + names: ['geo_shapes*'], + privileges: ['all'], + }, + ], + }, + }, + + ingest_pipelines_user: { + elasticsearch: { + cluster: ['manage_pipeline', 'cluster:monitor/nodes/info'], + }, + }, + + license_management_user: { + elasticsearch: { + cluster: ['manage'], + }, + }, + + logstash_read_user: { + elasticsearch: { + indices: [ + { + names: ['.logstash*'], + privileges: ['read'], + }, + ], + }, + }, + + remote_clusters_user: { + elasticsearch: { + cluster: ['manage'], + }, + }, }, defaultRoles: ['superuser'], }, diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 9b53e5ce2f7e7..e564c03f62d58 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -23,10 +23,14 @@ export function MachineLearningNavigationProvider({ }); }, - async navigateToStackManagement() { + async navigateToStackManagement({ expectMlLink = true }: { expectMlLink?: boolean } = {}) { await retry.tryForTime(60 * 1000, async () => { await PageObjects.common.navigateToApp('management'); - await testSubjects.existOrFail('jobsListLink', { timeout: 2000 }); + if (expectMlLink) { + await testSubjects.existOrFail('jobsListLink', { timeout: 2000 }); + } else { + await testSubjects.missingOrFail('jobsListLink', { timeout: 2000 }); + } }); }, @@ -84,22 +88,14 @@ export function MachineLearningNavigationProvider({ await this.navigateToArea('~mlMainTab & ~settings', 'mlPageSettings'); }, - async navigateToStackManagementJobsListPage({ - expectAccessDenied = false, - }: { - expectAccessDenied?: boolean; - } = {}) { + async navigateToStackManagementJobsListPage() { // clicks the jobsListLink and loads the jobs list page await testSubjects.click('jobsListLink'); await retry.tryForTime(60 * 1000, async () => { - if (expectAccessDenied === true) { - await testSubjects.existOrFail('mlPageAccessDenied'); - } else { - // verify that the overall page is present - await testSubjects.existOrFail('mlPageStackManagementJobsList'); - // verify that the default tab with the anomaly detection jobs list got loaded - await testSubjects.existOrFail('ml-jobs-list'); - } + // verify that the overall page is present + await testSubjects.existOrFail('mlPageStackManagementJobsList'); + // verify that the default tab with the anomaly detection jobs list got loaded + await testSubjects.existOrFail('ml-jobs-list'); }); }, diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index dd81c860e9fa8..5c42c1978a0b5 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -21,7 +21,7 @@ export class AlertingFixturePlugin implements Plugin { UserAtSpaceScenarios.forEach((scenario) => { it(`${scenario.id}`, async () => { @@ -35,13 +37,14 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'dual_privileges_all at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml, monitoring, and ES features are enabled const expected = mapValues( uiCapabilities.value!.catalogue, (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'ml_file_data_visualizer' && - catalogueId !== 'monitoring' + catalogueId !== 'monitoring' && + !esFeatureExceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; @@ -52,7 +55,8 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'everything_space_read at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring and enterprise search is enabled + // everything except spaces, ml, monitoring, the enterprise search suite, and ES features are enabled + // (easier to say: all "proper" Kibana features are enabled) const exceptions = [ 'ml', 'ml_file_data_visualizer', @@ -60,6 +64,8 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'enterpriseSearch', 'appSearch', 'workplaceSearch', + 'spaces', + ...esFeatureExceptions, ]; const expected = mapValues( uiCapabilities.value!.catalogue, @@ -68,10 +74,36 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } - // the nothing_space has no features enabled, so even if we have - // privileges to perform these actions, we won't be able to - case 'superuser at nothing_space': + // the nothing_space has no Kibana features enabled, so even if we have + // privileges to perform these actions, we won't be able to. + // Note that ES features may still be enabled if the user has privileges, since + // they cannot be disabled at the space level at this time. + case 'superuser at nothing_space': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything is disabled except for the es feature exceptions and spaces management + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => + esFeatureExceptions.includes(catalogueId) || catalogueId === 'spaces' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } + // the nothing_space has no Kibana features enabled, so even if we have + // privileges to perform these actions, we won't be able to. case 'global_all at nothing_space': + case 'dual_privileges_all at nothing_space': { + // everything is disabled except for spaces management + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId === 'spaces' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } + // the nothing_space has no Kibana features enabled, so even if we have + // privileges to perform these actions, we won't be able to. case 'global_read at nothing_space': case 'dual_privileges_all at nothing_space': case 'dual_privileges_read at nothing_space': @@ -88,7 +120,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything is disabled - const expected = mapValues(uiCapabilities.value!.catalogue, () => false); + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => false + ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 7852167fcc1cb..1f19228b2d958 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -13,6 +13,8 @@ import { UserScenarios } from '../scenarios'; export default function catalogueTests({ getService }: FtrProviderContext) { const uiCapabilitiesService: UICapabilitiesService = getService('uiCapabilities'); + const esFeatureExceptions = ['security', 'rollup_jobs', 'reporting', 'transform', 'watcher']; + describe('catalogue', () => { UserScenarios.forEach((scenario) => { it(`${scenario.fullName}`, async () => { @@ -35,13 +37,14 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'dual_privileges_all': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml, monitoring, and ES features are enabled const expected = mapValues( uiCapabilities.value!.catalogue, (enabled, catalogueId) => catalogueId !== 'ml' && + catalogueId !== 'monitoring' && catalogueId !== 'ml_file_data_visualizer' && - catalogueId !== 'monitoring' + !esFeatureExceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; @@ -58,6 +61,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'enterpriseSearch', 'appSearch', 'workplaceSearch', + ...esFeatureExceptions, ]; const expected = mapValues( uiCapabilities.value!.catalogue, diff --git a/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts index 2ef5108403427..baae3286ddb5d 100644 --- a/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts @@ -13,6 +13,8 @@ import { SpaceScenarios } from '../scenarios'; export default function catalogueTests({ getService }: FtrProviderContext) { const uiCapabilitiesService: UICapabilitiesService = getService('uiCapabilities'); + const esFeatureExceptions = ['security', 'rollup_jobs', 'reporting', 'transform', 'watcher']; + describe('catalogue', () => { SpaceScenarios.forEach((scenario) => { it(`${scenario.name}`, async () => { @@ -29,8 +31,12 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'nothing_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything is disabled - const expected = mapValues(uiCapabilities.value!.catalogue, () => false); + // everything is disabled except for ES features and spaces management + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => + esFeatureExceptions.includes(catalogueId) || catalogueId === 'spaces' + ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } From 520d348bf81e4bfeae2f6519a3fb5589071231c7 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 14 Sep 2020 16:10:04 +0200 Subject: [PATCH 02/91] [APM] Service inventory redesign (#76744) Co-authored-by: Elastic Machine --- .../anomaly_detection.test.ts} | 10 +- .../plugins/apm/common/anomaly_detection.ts | 77 ++++ x-pack/plugins/apm/common/service_map.test.ts | 12 +- x-pack/plugins/apm/common/service_map.ts | 2 +- .../ServiceMap/Popover/AnomalyDetection.tsx | 9 +- .../app/ServiceMap/Popover/getSeverity.ts | 30 -- .../app/ServiceMap/cytoscapeOptions.ts | 28 +- .../public/components/app/ServiceMap/icons.ts | 29 +- .../components/app/ServiceMap/icons/dark.svg | 1 - .../components/app/ServiceMap/index.tsx | 6 +- .../ServiceList/HealthBadge.tsx | 25 + .../ServiceOverview/ServiceList/MLCallout.tsx | 58 +++ .../ServiceList/ServiceListMetric.tsx | 49 ++ .../ServiceList/__test__/List.test.js | 52 ++- .../__test__/__snapshots__/List.test.js.snap | 84 ++-- .../ServiceList/__test__/props.json | 29 +- .../app/ServiceOverview/ServiceList/index.tsx | 180 ++++++-- .../__test__/ServiceOverview.test.tsx | 113 ++++- .../ServiceOverview.test.tsx.snap | 428 +++++++++++++++--- .../components/app/ServiceOverview/index.tsx | 59 ++- .../shared/AgentIcon/get_agent_icon.ts | 31 ++ .../AgentIcon}/icons/dot-net.svg | 0 .../AgentIcon}/icons/go.svg | 0 .../AgentIcon}/icons/java.svg | 0 .../AgentIcon}/icons/nodejs.svg | 0 .../AgentIcon}/icons/php.svg | 0 .../AgentIcon}/icons/python.svg | 0 .../AgentIcon}/icons/ruby.svg | 0 .../AgentIcon}/icons/rumjs.svg | 0 .../components/shared/AgentIcon/index.tsx | 21 + .../components/shared/ManagedTable/index.tsx | 18 +- .../SelectAnomalySeverity.tsx | 6 +- .../shared/charts/SparkPlot/index.tsx | 66 +++ .../public/hooks/useAnomalyDetectionJobs.ts | 18 + .../apm/public/hooks/useLocalStorage.ts | 54 +++ x-pack/plugins/apm/scripts/tsconfig.json | 3 +- ...transaction_duration_anomaly_alert_type.ts | 5 + .../lib/helpers/get_bucket_size/index.ts | 23 +- .../plugins/apm/server/lib/helpers/metrics.ts | 2 +- .../apm/server/lib/helpers/setup_request.ts | 20 +- .../java/gc/fetch_and_transform_gc_metrics.ts | 6 +- .../lib/service_map/get_service_anomalies.ts | 18 +- .../get_service_map_service_node_info.test.ts | 3 + .../__snapshots__/queries.test.ts.snap | 66 ++- .../get_services/get_services_items.ts | 20 +- .../get_services/get_services_items_stats.ts | 179 +++++++- .../server/lib/services/get_services/index.ts | 12 +- .../apm/server/lib/services/queries.test.ts | 2 +- .../lib/transaction_groups/get_error_rate.ts | 2 +- .../avg_duration_by_browser/fetcher.ts | 2 +- .../charts/get_anomaly_data/index.ts | 16 +- .../charts/get_timeseries_data/fetcher.ts | 2 +- .../charts/get_timeseries_data/index.ts | 2 +- .../plugins/apm/server/routes/service_map.ts | 6 +- x-pack/plugins/apm/server/routes/services.ts | 12 +- .../routes/settings/anomaly_detection.ts | 7 +- .../apm/typings/elasticsearch/aggregations.ts | 6 + .../public/hooks/use_chart_theme.tsx | 5 +- .../observability/public/hooks/use_theme.tsx | 13 + x-pack/plugins/observability/public/index.ts | 3 + .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../basic/tests/services/agent_name.ts | 7 +- .../basic/tests/services/top_services.ts | 230 ++++++++-- .../apm_api_integration/trial/tests/index.ts | 1 + .../trial/tests/services/top_services.ts | 75 +++ 66 files changed, 1809 insertions(+), 440 deletions(-) rename x-pack/plugins/apm/{public/components/app/ServiceMap/Popover/getSeverity.test.ts => common/anomaly_detection.test.ts} (74%) delete mode 100644 x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts delete mode 100644 x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg create mode 100644 x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx create mode 100644 x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx create mode 100644 x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/dot-net.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/go.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/java.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/nodejs.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/php.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/python.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/ruby.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/AgentIcon}/icons/rumjs.svg (100%) create mode 100644 x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx create mode 100644 x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts create mode 100644 x-pack/plugins/apm/public/hooks/useLocalStorage.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_theme.tsx create mode 100644 x-pack/test/apm_api_integration/trial/tests/services/top_services.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts b/x-pack/plugins/apm/common/anomaly_detection.test.ts similarity index 74% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts rename to x-pack/plugins/apm/common/anomaly_detection.test.ts index 52b7d54236db6..21963b5300f83 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getSeverity, severity } from './getSeverity'; +import { getSeverity, Severity } from './anomaly_detection'; describe('getSeverity', () => { describe('when score is undefined', () => { @@ -15,25 +15,25 @@ describe('getSeverity', () => { describe('when score < 25', () => { it('returns warning', () => { - expect(getSeverity(10)).toEqual(severity.warning); + expect(getSeverity(10)).toEqual(Severity.warning); }); }); describe('when score is between 25 and 50', () => { it('returns minor', () => { - expect(getSeverity(40)).toEqual(severity.minor); + expect(getSeverity(40)).toEqual(Severity.minor); }); }); describe('when score is between 50 and 75', () => { it('returns major', () => { - expect(getSeverity(60)).toEqual(severity.major); + expect(getSeverity(60)).toEqual(Severity.major); }); }); describe('when score is 75 or more', () => { it('returns critical', () => { - expect(getSeverity(100)).toEqual(severity.critical); + expect(getSeverity(100)).toEqual(Severity.critical); }); }); }); diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 07270b572a4be..5d80ee6381267 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { EuiTheme } from '../../../legacy/common/eui_styled_components'; export interface ServiceAnomalyStats { transactionType?: string; @@ -13,6 +14,82 @@ export interface ServiceAnomalyStats { jobId?: string; } +export enum Severity { + critical = 'critical', + major = 'major', + minor = 'minor', + warning = 'warning', +} + +// TODO: Replace with `getSeverity` from: +// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 +export function getSeverity(score?: number) { + if (typeof score !== 'number') { + return undefined; + } else if (score < 25) { + return Severity.warning; + } else if (score >= 25 && score < 50) { + return Severity.minor; + } else if (score >= 50 && score < 75) { + return Severity.major; + } else if (score >= 75) { + return Severity.critical; + } else { + return undefined; + } +} + +export function getSeverityColor(theme: EuiTheme, severity?: Severity) { + switch (severity) { + case Severity.warning: + return theme.eui.euiColorVis0; + case Severity.minor: + case Severity.major: + return theme.eui.euiColorVis5; + case Severity.critical: + return theme.eui.euiColorVis9; + default: + return; + } +} + +export function getSeverityLabel(severity?: Severity) { + switch (severity) { + case Severity.critical: + return i18n.translate( + 'xpack.apm.servicesTable.serviceHealthStatus.critical', + { + defaultMessage: 'Critical', + } + ); + + case Severity.major: + case Severity.minor: + return i18n.translate( + 'xpack.apm.servicesTable.serviceHealthStatus.warning', + { + defaultMessage: 'Warning', + } + ); + + case Severity.warning: + return i18n.translate( + 'xpack.apm.servicesTable.serviceHealthStatus.healthy', + { + defaultMessage: 'Healthy', + } + ); + + default: + return i18n.translate( + 'xpack.apm.servicesTable.serviceHealthStatus.unknown', + { + defaultMessage: 'Unknown', + } + ); + } +} + export const ML_ERRORS = { INVALID_LICENSE: i18n.translate( 'xpack.apm.anomaly_detection.error.invalid_license', diff --git a/x-pack/plugins/apm/common/service_map.test.ts b/x-pack/plugins/apm/common/service_map.test.ts index 346403efc46ae..31f439a7aaec9 100644 --- a/x-pack/plugins/apm/common/service_map.test.ts +++ b/x-pack/plugins/apm/common/service_map.test.ts @@ -8,7 +8,7 @@ import { License } from '../../licensing/common/license'; import * as serviceMap from './service_map'; describe('service map helpers', () => { - describe('isValidPlatinumLicense', () => { + describe('isActivePlatinumLicense', () => { describe('with an expired license', () => { it('returns false', () => { const license = new License({ @@ -22,7 +22,7 @@ describe('service map helpers', () => { signature: 'test signature', }); - expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false); + expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false); }); }); @@ -39,7 +39,7 @@ describe('service map helpers', () => { signature: 'test signature', }); - expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false); + expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false); }); }); @@ -56,7 +56,7 @@ describe('service map helpers', () => { signature: 'test signature', }); - expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true); + expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true); }); }); @@ -73,7 +73,7 @@ describe('service map helpers', () => { signature: 'test signature', }); - expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true); + expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true); }); }); @@ -90,7 +90,7 @@ describe('service map helpers', () => { signature: 'test signature', }); - expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true); + expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true); }); }); }); diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 7f46fc685d9ca..1dc4d598cd2ee 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -46,7 +46,7 @@ export interface ServiceNodeStats { avgErrorRate: number | null; } -export function isValidPlatinumLicense(license: ILicense) { +export function isActivePlatinumLicense(license: ILicense) { return license.isActive && license.hasAtLeast('platinum'); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index b3d19e1aab2cc..5699d0b56219b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -18,10 +18,13 @@ import { useTheme } from '../../../../hooks/useTheme'; import { fontSize, px } from '../../../../style/variables'; import { asInteger, asDuration } from '../../../../utils/formatters'; import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { getSeverityColor, popoverWidth } from '../cytoscapeOptions'; +import { popoverWidth } from '../cytoscapeOptions'; import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; -import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; -import { getSeverity } from './getSeverity'; +import { + getSeverity, + getSeverityColor, + ServiceAnomalyStats, +} from '../../../../../common/anomaly_detection'; const HealthStatusTitle = styled(EuiTitle)` display: inline; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts deleted file mode 100644 index f4eb2033e9231..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export enum severity { - critical = 'critical', - major = 'major', - minor = 'minor', - warning = 'warning', -} - -// TODO: Replace with `getSeverity` from: -// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 -export function getSeverity(score?: number) { - if (typeof score !== 'number') { - return undefined; - } else if (score < 25) { - return severity.warning; - } else if (score >= 25 && score < 50) { - return severity.minor; - } else if (score >= 50 && score < 75) { - return severity.major; - } else if (score >= 75) { - return severity.critical; - } else { - return undefined; - } -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 9fedcc70bbbcf..1ac7157cc2aad 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -11,25 +11,15 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; import { defaultIcon, iconForNode } from './icons'; -import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; -import { severity, getSeverity } from './Popover/getSeverity'; +import { + getSeverity, + getSeverityColor, + ServiceAnomalyStats, + Severity, +} from '../../../../common/anomaly_detection'; export const popoverWidth = 280; -export function getSeverityColor(theme: EuiTheme, nodeSeverity?: string) { - switch (nodeSeverity) { - case severity.warning: - return theme.eui.euiColorVis0; - case severity.minor: - case severity.major: - return theme.eui.euiColorVis5; - case severity.critical: - return theme.eui.euiColorVis9; - default: - return; - } -} - function getNodeSeverity(el: cytoscape.NodeSingular) { const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data( 'serviceAnomalyStats' @@ -60,7 +50,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction< cytoscape.Css.LineStyle > = (el: cytoscape.NodeSingular) => { const nodeSeverity = getNodeSeverity(el); - if (nodeSeverity === severity.critical) { + if (nodeSeverity === Severity.critical) { return 'double'; } else { return 'solid'; @@ -70,9 +60,9 @@ const getBorderStyle: cytoscape.Css.MapperFunction< function getBorderWidth(el: cytoscape.NodeSingular) { const nodeSeverity = getNodeSeverity(el); - if (nodeSeverity === severity.minor || nodeSeverity === severity.major) { + if (nodeSeverity === Severity.minor || nodeSeverity === Severity.major) { return 4; - } else if (nodeSeverity === severity.critical) { + } else if (nodeSeverity === Severity.critical) { return 8; } else { return 4; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts index 2f4cc0d39d71c..c85cf85d38702 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -5,7 +5,6 @@ */ import cytoscape from 'cytoscape'; -import { getNormalizedAgentName } from '../../../../common/agent_name'; import { AGENT_NAME, SPAN_SUBTYPE, @@ -13,29 +12,22 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import awsIcon from './icons/aws.svg'; import cassandraIcon from './icons/cassandra.svg'; -import darkIcon from './icons/dark.svg'; import databaseIcon from './icons/database.svg'; import defaultIconImport from './icons/default.svg'; import documentsIcon from './icons/documents.svg'; -import dotNetIcon from './icons/dot-net.svg'; import elasticsearchIcon from './icons/elasticsearch.svg'; import globeIcon from './icons/globe.svg'; -import goIcon from './icons/go.svg'; import graphqlIcon from './icons/graphql.svg'; import grpcIcon from './icons/grpc.svg'; import handlebarsIcon from './icons/handlebars.svg'; -import javaIcon from './icons/java.svg'; import kafkaIcon from './icons/kafka.svg'; import mongodbIcon from './icons/mongodb.svg'; import mysqlIcon from './icons/mysql.svg'; -import nodeJsIcon from './icons/nodejs.svg'; -import phpIcon from './icons/php.svg'; import postgresqlIcon from './icons/postgresql.svg'; -import pythonIcon from './icons/python.svg'; import redisIcon from './icons/redis.svg'; -import rubyIcon from './icons/ruby.svg'; -import rumJsIcon from './icons/rumjs.svg'; import websocketIcon from './icons/websocket.svg'; +import javaIcon from '../../shared/AgentIcon/icons/java.svg'; +import { getAgentIcon } from '../../shared/AgentIcon/get_agent_icon'; export const defaultIcon = defaultIconImport; @@ -74,23 +66,6 @@ const typeIcons: { [key: string]: { [key: string]: string } } = { }, }; -const agentIcons: { [key: string]: string } = { - dark: darkIcon, - dotnet: dotNetIcon, - go: goIcon, - java: javaIcon, - 'js-base': rumJsIcon, - nodejs: nodeJsIcon, - php: phpIcon, - python: pythonIcon, - ruby: rubyIcon, -}; - -function getAgentIcon(agentName?: string) { - const normalizedAgentName = getNormalizedAgentName(agentName); - return normalizedAgentName && agentIcons[normalizedAgentName]; -} - function getSpanIcon(type?: string, subtype?: string) { if (!type) { return; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg deleted file mode 100644 index 9ae4b31c1a0d6..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index 83fab95bc91c9..cb5a57e9ab9fb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useTheme } from '../../../hooks/useTheme'; import { invalidLicenseMessage, - isValidPlatinumLicense, + isActivePlatinumLicense, } from '../../../../common/service_map'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; @@ -36,7 +36,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { const { data = { elements: [] } } = useFetcher(() => { // When we don't have a license or a valid license, don't make the request. - if (!license || !isValidPlatinumLicense(license)) { + if (!license || !isActivePlatinumLicense(license)) { return; } @@ -66,7 +66,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { return null; } - return isValidPlatinumLicense(license) ? ( + return isActivePlatinumLicense(license) ? (
+ {getSeverityLabel(severity)} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx new file mode 100644 index 0000000000000..dd632db0f15fe --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { APMLink } from '../../../shared/Links/apm/APMLink'; + +export function MLCallout({ onDismiss }: { onDismiss: () => void }) { + return ( + +

+ {i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.content', { + defaultMessage: `Our integration with ML anomaly detection will enable you to see your services' health status`, + })} +

+ + + + + {i18n.translate( + 'xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton', + { + defaultMessage: `Learn more`, + } + )} + + + + + onDismiss()}> + {i18n.translate( + 'xpack.apm.serviceOverview.mlNudgeMessage.dismissButton', + { + defaultMessage: `Dismiss message`, + } + )} + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx new file mode 100644 index 0000000000000..c94c94d4a0b72 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; + +import React from 'react'; +import { useTheme } from '../../../../hooks/useTheme'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { getEmptySeries } from '../../../shared/charts/CustomPlot/getEmptySeries'; +import { SparkPlot } from '../../../shared/charts/SparkPlot'; + +export function ServiceListMetric({ + color, + series, + valueLabel, +}: { + color: 'euiColorVis1' | 'euiColorVis0' | 'euiColorVis7'; + series?: Array<{ x: number; y: number | null }>; + valueLabel: React.ReactNode; +}) { + const theme = useTheme(); + + const { + urlParams: { start, end }, + } = useUrlParams(); + + const colorValue = theme.eui[color]; + + return ( + + + + + + {valueLabel} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js index 927779b571fd8..519d74827097b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js @@ -15,34 +15,62 @@ describe('ServiceOverview -> List', () => { mockMoment(); }); - it('should render empty state', () => { + it('renders empty state', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - it('should render with data', () => { + it('renders with data', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - it('should render columns correctly', () => { + it('renders columns correctly', () => { const service = { serviceName: 'opbeans-python', agentName: 'python', - transactionsPerMinute: 86.93333333333334, - errorsPerMinute: 12.6, - avgResponseTime: 91535.42944785276, + transactionsPerMinute: { + value: 86.93333333333334, + timeseries: [], + }, + errorsPerMinute: { + value: 12.6, + timeseries: [], + }, + avgResponseTime: { + value: 91535.42944785276, + timeseries: [], + }, environments: ['test'], }; const renderedColumns = SERVICE_COLUMNS.map((c) => c.render(service[c.field], service) ); + expect(renderedColumns[0]).toMatchSnapshot(); - expect(renderedColumns.slice(2)).toEqual([ - 'python', - '92 ms', - '86.9 tpm', - '12.6 err.', - ]); + }); + + describe('without ML data', () => { + it('does not render health column', () => { + const wrapper = shallow( + + ); + + const columns = wrapper.props().columns; + + expect(columns[0].field).not.toBe('severity'); + }); + }); + + describe('with ML data', () => { + it('renders health column', () => { + const wrapper = shallow( + + ); + + const columns = wrapper.props().columns; + + expect(columns[0].field).toBe('severity'); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap index 146f6f58031bb..da3f6ae89940a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap @@ -1,21 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ServiceOverview -> List should render columns correctly 1`] = ` - - - opbeans-python - - -`; +exports[`ServiceOverview -> List renders columns correctly 1`] = ``; -exports[`ServiceOverview -> List should render empty state 1`] = ` +exports[`ServiceOverview -> List renders empty state 1`] = ` List should render empty state 1`] = ` "name": "Environment", "render": [Function], "sortable": true, - "width": "20%", - }, - Object { - "field": "agentName", - "name": "Agent", - "render": [Function], - "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "avgResponseTime", "name": "Avg. response time", "render": [Function], "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "transactionsPerMinute", "name": "Trans. per minute", "render": [Function], "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "errorsPerMinute", - "name": "Errors per minute", + "name": "Error rate %", "render": [Function], "sortable": true, + "width": "160px", }, ] } initialPageSize={50} - initialSortField="serviceName" + initialSortDirection="desc" + initialSortField="severity" items={Array []} + sortFn={[Function]} /> `; -exports[`ServiceOverview -> List should render with data 1`] = ` +exports[`ServiceOverview -> List renders with data 1`] = ` List should render with data 1`] = ` "name": "Environment", "render": [Function], "sortable": true, - "width": "20%", - }, - Object { - "field": "agentName", - "name": "Agent", - "render": [Function], - "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "avgResponseTime", "name": "Avg. response time", "render": [Function], "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "transactionsPerMinute", "name": "Trans. per minute", "render": [Function], "sortable": true, + "width": "160px", }, Object { + "align": "left", "dataType": "number", "field": "errorsPerMinute", - "name": "Errors per minute", + "name": "Error rate %", "render": [Function], "sortable": true, + "width": "160px", }, ] } initialPageSize={50} - initialSortField="serviceName" + initialSortDirection="desc" + initialSortField="severity" items={ Array [ Object { @@ -125,19 +115,35 @@ exports[`ServiceOverview -> List should render with data 1`] = ` "environments": Array [ "test", ], - "errorsPerMinute": 46.06666666666667, + "errorsPerMinute": Object { + "timeseries": Array [], + "value": 46.06666666666667, + }, "serviceName": "opbeans-node", - "transactionsPerMinute": 0, + "transactionsPerMinute": Object { + "timeseries": Array [], + "value": 0, + }, }, Object { "agentName": "python", - "avgResponseTime": 91535.42944785276, + "avgResponseTime": Object { + "timeseries": Array [], + "value": 91535.42944785276, + }, "environments": Array [], - "errorsPerMinute": 12.6, + "errorsPerMinute": Object { + "timeseries": Array [], + "value": 12.6, + }, "serviceName": "opbeans-python", - "transactionsPerMinute": 86.93333333333334, + "transactionsPerMinute": Object { + "timeseries": Array [], + "value": 86.93333333333334, + }, }, ] } + sortFn={[Function]} /> `; diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json index 2379d27407e04..7f24ad8b0d308 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json @@ -3,17 +3,34 @@ { "serviceName": "opbeans-node", "agentName": "nodejs", - "transactionsPerMinute": 0, - "errorsPerMinute": 46.06666666666667, + "transactionsPerMinute": { + "value": 0, + "timeseries": [] + }, + "errorsPerMinute": { + "value": 46.06666666666667, + "timeseries": [] + }, "avgResponseTime": null, - "environments": ["test"] + "environments": [ + "test" + ] }, { "serviceName": "opbeans-python", "agentName": "python", - "transactionsPerMinute": 86.93333333333334, - "errorsPerMinute": 12.6, - "avgResponseTime": 91535.42944785276, + "transactionsPerMinute": { + "value": 86.93333333333334, + "timeseries": [] + }, + "errorsPerMinute": { + "value": 12.6, + "timeseries": [] + }, + "avgResponseTime": { + "value": 91535.42944785276, + "timeseries": [] + }, "environments": [] } ] diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index 90cc9af45273e..ce256137481cb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -4,24 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import styled from 'styled-components'; +import { ValuesType } from 'utility-types'; +import { orderBy } from 'lodash'; +import { asPercent } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { fontSizes, truncate } from '../../../../style/variables'; +import { fontSizes, px, truncate, unit } from '../../../../style/variables'; import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters'; -import { ManagedTable } from '../../../shared/ManagedTable'; +import { ManagedTable, ITableColumn } from '../../../shared/ManagedTable'; import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; +import { AgentIcon } from '../../../shared/AgentIcon'; +import { Severity } from '../../../../../common/anomaly_detection'; +import { HealthBadge } from './HealthBadge'; +import { ServiceListMetric } from './ServiceListMetric'; interface Props { items: ServiceListAPIResponse['items']; noItemsMessage?: React.ReactNode; + displayHealthStatus: boolean; } +type ServiceListItem = ValuesType; + function formatNumber(value: number) { if (value === 0) { return '0'; @@ -41,7 +51,18 @@ const AppLink = styled(TransactionOverviewLink)` ${truncate('100%')}; `; -export const SERVICE_COLUMNS = [ +export const SERVICE_COLUMNS: Array> = [ + { + field: 'severity', + name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { + defaultMessage: 'Health', + }), + width: px(unit * 6), + sortable: true, + render: (_, { severity }) => { + return ; + }, + }, { field: 'serviceName', name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { @@ -49,9 +70,24 @@ export const SERVICE_COLUMNS = [ }), width: '40%', sortable: true, - render: (serviceName: string) => ( - - {formatString(serviceName)} + render: (_, { serviceName, agentName }) => ( + + + {agentName && ( + + + + )} + + + {formatString(serviceName)} + + + ), }, @@ -60,20 +96,12 @@ export const SERVICE_COLUMNS = [ name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', { defaultMessage: 'Environment', }), - width: '20%', + width: px(unit * 10), sortable: true, - render: (environments: string[]) => ( - + render: (_, { environments }) => ( + ), }, - { - field: 'agentName', - name: i18n.translate('xpack.apm.servicesTable.agentColumnLabel', { - defaultMessage: 'Agent', - }), - sortable: true, - render: (agentName: string) => formatString(agentName), - }, { field: 'avgResponseTime', name: i18n.translate('xpack.apm.servicesTable.avgResponseTimeColumnLabel', { @@ -81,7 +109,15 @@ export const SERVICE_COLUMNS = [ }), sortable: true, dataType: 'number', - render: (time: number) => asMillisecondDuration(time), + render: (_, { avgResponseTime }) => ( + + ), + align: 'left', + width: px(unit * 10), }, { field: 'transactionsPerMinute', @@ -93,39 +129,107 @@ export const SERVICE_COLUMNS = [ ), sortable: true, dataType: 'number', - render: (value: number) => - `${formatNumber(value)} ${i18n.translate( - 'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel', - { - defaultMessage: 'tpm', - } - )}`, + render: (_, { transactionsPerMinute }) => ( + + ), + align: 'left', + width: px(unit * 10), }, { field: 'errorsPerMinute', - name: i18n.translate('xpack.apm.servicesTable.errorsPerMinuteColumnLabel', { - defaultMessage: 'Errors per minute', + name: i18n.translate('xpack.apm.servicesTable.transactionErrorRate', { + defaultMessage: 'Error rate %', }), sortable: true, dataType: 'number', - render: (value: number) => - `${formatNumber(value)} ${i18n.translate( - 'xpack.apm.servicesTable.errorsPerMinuteUnitLabel', - { - defaultMessage: 'err.', - } - )}`, + render: (_, { transactionErrorRate }) => { + const value = transactionErrorRate?.value; + + const valueLabel = + value !== null && value !== undefined ? asPercent(value, 1) : ''; + + return ( + + ); + }, + align: 'left', + width: px(unit * 10), }, ]; -export function ServiceList({ items, noItemsMessage }: Props) { +const SEVERITY_ORDER = [ + Severity.warning, + Severity.minor, + Severity.major, + Severity.critical, +]; + +export function ServiceList({ + items, + displayHealthStatus, + noItemsMessage, +}: Props) { + const columns = displayHealthStatus + ? SERVICE_COLUMNS + : SERVICE_COLUMNS.filter((column) => column.field !== 'severity'); + return ( { + // For severity, sort items by severity first, then by TPM + + return sortField === 'severity' + ? orderBy( + itemsToSort, + [ + (item) => { + return item.severity + ? SEVERITY_ORDER.indexOf(item.severity) + : -1; + }, + (item) => item.transactionsPerMinute?.value ?? 0, + ], + [sortDirection, sortDirection] + ) + : orderBy( + itemsToSort, + (item) => { + switch (sortField) { + case 'avgResponseTime': + return item.avgResponseTime?.value ?? 0; + case 'transactionsPerMinute': + return item.transactionsPerMinute?.value ?? 0; + case 'transactionErrorRate': + return item.transactionErrorRate?.value ?? 0; + + default: + return item[sortField as keyof typeof item]; + } + }, + sortDirection + ); + }} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index d9c5ff5130df6..8eeff018ad03f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -8,6 +8,7 @@ import { render, wait, waitForElement } from '@testing-library/react'; import { CoreStart } from 'kibana/public'; import React, { FunctionComponent, ReactChild } from 'react'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { merge } from 'lodash'; import { ServiceOverview } from '..'; import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; import { @@ -17,35 +18,38 @@ import { import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters'; import * as urlParamsHooks from '../../../../hooks/useUrlParams'; +import * as useAnomalyDetectionJobs from '../../../../hooks/useAnomalyDetectionJobs'; import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock'; +import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiStats: () => {} }, } as Partial); +const addWarning = jest.fn(); +const httpGet = jest.fn(); + function wrapper({ children }: { children: ReactChild }) { + const mockPluginContext = (merge({}, mockApmPluginContextValue, { + core: { + http: { + get: httpGet, + }, + notifications: { + toasts: { + addWarning, + }, + }, + }, + }) as unknown) as ApmPluginContextValue; + return ( - - {children} - + + + {children} + + ); } @@ -56,9 +60,6 @@ function renderServiceOverview() { }); } -const addWarning = jest.fn(); -const httpGet = jest.fn(); - describe('Service Overview -> View', () => { beforeEach(() => { // @ts-expect-error @@ -80,6 +81,17 @@ describe('Service Overview -> View', () => { clearValues: () => null, status: FETCH_STATUS.SUCCESS, }); + + jest + .spyOn(useAnomalyDetectionJobs, 'useAnomalyDetectionJobs') + .mockReturnValue({ + status: FETCH_STATUS.SUCCESS, + data: { + jobs: [], + hasLegacyJobs: false, + }, + refetch: () => undefined, + }); }); afterEach(() => { @@ -99,6 +111,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 200, avgResponseTime: 300, environments: ['test', 'dev'], + severity: 1, }, { serviceName: 'My Go Service', @@ -107,6 +120,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 500, avgResponseTime: 600, environments: [], + severity: 10, }, ], }); @@ -195,4 +209,57 @@ describe('Service Overview -> View', () => { expect(addWarning).not.toHaveBeenCalled(); }); }); + + describe('when ML data is not found', () => { + it('does not render the health column', async () => { + httpGet.mockResolvedValueOnce({ + hasLegacyData: false, + hasHistoricalData: true, + items: [ + { + serviceName: 'My Python Service', + agentName: 'python', + transactionsPerMinute: 100, + errorsPerMinute: 200, + avgResponseTime: 300, + environments: ['test', 'dev'], + }, + ], + }); + + const { queryByText } = renderServiceOverview(); + + // wait for requests to be made + await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + + expect(queryByText('Health')).toBeNull(); + }); + }); + + describe('when ML data is found', () => { + it('renders the health column', async () => { + httpGet.mockResolvedValueOnce({ + hasLegacyData: false, + hasHistoricalData: true, + items: [ + { + serviceName: 'My Python Service', + agentName: 'python', + transactionsPerMinute: 100, + errorsPerMinute: 200, + avgResponseTime: 300, + environments: ['test', 'dev'], + severity: 1, + }, + ], + }); + + const { queryAllByText } = renderServiceOverview(); + + // wait for requests to be made + await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + + expect(queryAllByText('Health').length).toBeGreaterThan(1); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index 6d447887627bf..b56f7d6820274 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -7,7 +7,7 @@ NodeList [ >
- Name + Health
- - My Go Service - + + Unknown + +
- Environment + Name
+ > + + + +
- Agent + Environment
- go + + + + test + + + + + + + dev + + +
- 0.6 ms +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 ms +
+
- 400.0 tpm +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 tpm +
+
- Errors per minute + Error rate %
- 500.0 err. +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+
, @@ -247,87 +423,91 @@ NodeList [ >
- Name + Health
- - My Python Service - + + Unknown + +
- Environment + Name
- - - test - - - - - - +
+
- dev - - + + My Go Service + +
+
- Agent + Environment
- python -
+ />
- 0.3 ms +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 ms +
+
- 100.0 tpm +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 tpm +
+
- Errors per minute + Error rate %
- 200.0 err. +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+
, diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx index 7146e471a7f82..d9d2cffb67620 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useMemo } from 'react'; import url from 'url'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; -import { useFetcher } from '../../../hooks/useFetcher'; +import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher'; import { NoServicesMessage } from './NoServicesMessage'; import { ServiceList } from './ServiceList'; import { useUrlParams } from '../../../hooks/useUrlParams'; @@ -18,8 +18,11 @@ import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { MLCallout } from './ServiceList/MLCallout'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; +import { useAnomalyDetectionJobs } from '../../../hooks/useAnomalyDetectionJobs'; -const initalData = { +const initialData = { items: [], hasHistoricalData: true, hasLegacyData: false, @@ -33,7 +36,7 @@ export function ServiceOverview() { urlParams: { start, end }, uiFilters, } = useUrlParams(); - const { data = initalData, status } = useFetcher( + const { data = initialData, status } = useFetcher( (callApmApi) => { if (start && end) { return callApmApi({ @@ -93,6 +96,26 @@ export function ServiceOverview() { [] ); + const { + data: anomalyDetectionJobsData, + status: anomalyDetectionJobsStatus, + } = useAnomalyDetectionJobs(); + + const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage( + 'apm.userHasDismissedServiceInventoryMlCallout', + false + ); + + const canCreateJob = !!core.application.capabilities.ml?.canCreateJob; + + const displayMlCallout = + anomalyDetectionJobsStatus === FETCH_STATUS.SUCCESS && + !anomalyDetectionJobsData?.jobs.length && + canCreateJob && + !userHasDismissedCallout; + + const displayHealthStatus = data.items.some((item) => 'severity' in item); + return ( <> @@ -101,17 +124,27 @@ export function ServiceOverview() { - - + {displayMlCallout ? ( + + setUserHasDismissedCallout(true)} /> + + ) : null} + + + + } /> - } - /> - + + + diff --git a/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts b/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts new file mode 100644 index 0000000000000..2475eecee8e34 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getNormalizedAgentName } from '../../../../common/agent_name'; +import dotNetIcon from './icons/dot-net.svg'; +import goIcon from './icons/go.svg'; +import javaIcon from './icons/java.svg'; +import nodeJsIcon from './icons/nodejs.svg'; +import phpIcon from './icons/php.svg'; +import pythonIcon from './icons/python.svg'; +import rubyIcon from './icons/ruby.svg'; +import rumJsIcon from './icons/rumjs.svg'; + +const agentIcons: { [key: string]: string } = { + dotnet: dotNetIcon, + go: goIcon, + java: javaIcon, + 'js-base': rumJsIcon, + nodejs: nodeJsIcon, + php: phpIcon, + python: pythonIcon, + ruby: rubyIcon, +}; + +export function getAgentIcon(agentName?: string) { + const normalizedAgentName = getNormalizedAgentName(agentName); + return normalizedAgentName && agentIcons[normalizedAgentName]; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/dot-net.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/dot-net.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/go.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/go.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/java.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/java.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/nodejs.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/nodejs.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/php.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/php.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/python.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/python.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/ruby.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/ruby.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/rumjs.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/rumjs.svg diff --git a/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx b/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx new file mode 100644 index 0000000000000..5646fc05bd28f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { getAgentIcon } from './get_agent_icon'; +import { px } from '../../../style/variables'; + +interface Props { + agentName: AgentName; +} + +export function AgentIcon(props: Props) { + const { agentName } = props; + + const icon = getAgentIcon(agentName); + + return {agentName}; +} diff --git a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx index 9fe52aab83641..9db563a0f6ba8 100644 --- a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx @@ -33,9 +33,22 @@ interface Props { hidePerPageOptions?: boolean; noItemsMessage?: React.ReactNode; sortItems?: boolean; + sortFn?: ( + items: T[], + sortField: string, + sortDirection: 'asc' | 'desc' + ) => T[]; pagination?: boolean; } +function defaultSortFn( + items: T[], + sortField: string, + sortDirection: 'asc' | 'desc' +) { + return orderBy(items, sortField, sortDirection); +} + function UnoptimizedManagedTable(props: Props) { const history = useHistory(); const { @@ -48,6 +61,7 @@ function UnoptimizedManagedTable(props: Props) { hidePerPageOptions = true, noItemsMessage, sortItems = true, + sortFn = defaultSortFn, pagination = true, } = props; @@ -62,11 +76,11 @@ function UnoptimizedManagedTable(props: Props) { const renderedItems = useMemo(() => { const sortedItems = sortItems - ? orderBy(items, sortField, sortDirection as 'asc' | 'desc') + ? sortFn(items, sortField, sortDirection as 'asc' | 'desc') : items; return sortedItems.slice(page * pageSize, (page + 1) * pageSize); - }, [page, pageSize, sortField, sortDirection, items, sortItems]); + }, [page, pageSize, sortField, sortDirection, items, sortItems, sortFn]); const sort = useMemo(() => { return { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx index fcbdb900368ea..5bddfc67200b1 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx @@ -8,9 +8,11 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; -import { getSeverityColor } from '../../app/ServiceMap/cytoscapeOptions'; +import { + getSeverityColor, + Severity, +} from '../../../../common/anomaly_detection'; import { useTheme } from '../../../hooks/useTheme'; -import { severity as Severity } from '../../app/ServiceMap/Popover/getSeverity'; type SeverityScore = 0 | 25 | 50 | 75; const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75]; diff --git a/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx new file mode 100644 index 0000000000000..18b914afea995 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ScaleType, Chart, Settings, AreaSeries } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; +import { px } from '../../../../style/variables'; +import { useChartTheme } from '../../../../../../observability/public'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; + +interface Props { + color: string; + series: Array<{ x: number; y: number | null }>; +} + +export function SparkPlot(props: Props) { + const { series, color } = props; + const chartTheme = useChartTheme(); + + const isEmpty = series.every((point) => point.y === null); + + if (isEmpty) { + return ( + + + + + + + {NOT_AVAILABLE_LABEL} + + + + ); + } + + return ( + + + + + ); +} diff --git a/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts b/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts new file mode 100644 index 0000000000000..56c58bc82967b --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useFetcher } from './useFetcher'; + +export function useAnomalyDetectionJobs() { + return useFetcher( + (callApmApi) => + callApmApi({ + pathname: `/api/apm/settings/anomaly-detection`, + }), + [], + { showToastOnError: false } + ); +} diff --git a/x-pack/plugins/apm/public/hooks/useLocalStorage.ts b/x-pack/plugins/apm/public/hooks/useLocalStorage.ts new file mode 100644 index 0000000000000..cf37b45045f4d --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/useLocalStorage.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect } from 'react'; + +export function useLocalStorage(key: string, defaultValue: T) { + const [item, setItem] = useState(getFromStorage()); + + function getFromStorage() { + const storedItem = window.localStorage.getItem(key); + + let toStore: T = defaultValue; + + if (storedItem !== null) { + try { + toStore = JSON.parse(storedItem) as T; + } catch (err) { + window.localStorage.removeItem(key); + // eslint-disable-next-line no-console + console.log(`Unable to decode: ${key}`); + } + } + + return toStore; + } + + const updateFromStorage = () => { + const storedItem = getFromStorage(); + setItem(storedItem); + }; + + const saveToStorage = (value: T) => { + if (value === undefined) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(value)); + updateFromStorage(); + } + }; + + useEffect(() => { + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key === key) { + updateFromStorage(); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [item, saveToStorage] as const; +} diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json index 64602bc6b2769..f1643608496ad 100644 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ b/x-pack/plugins/apm/scripts/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../../../tsconfig.base.json", "include": [ - "./**/*" + "./**/*", + "../observability" ], "exclude": [], "compilerOptions": { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index e7eb7b8de65e3..93af51b572aa5 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -81,6 +81,11 @@ export function registerTransactionDurationAnomalyAlertType({ anomalyDetectors, alertParams.environment ); + + if (mlJobIds.length === 0) { + return {}; + } + const anomalySearchParams = { body: { size: 0, diff --git a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts index 75b0471424e79..5b78d97d5b681 100644 --- a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts @@ -7,22 +7,23 @@ import moment from 'moment'; // @ts-expect-error import { calculateAuto } from './calculate_auto'; -// @ts-expect-error -import { unitToSeconds } from './unit_to_seconds'; -export function getBucketSize(start: number, end: number, interval: string) { +export function getBucketSize( + start: number, + end: number, + numBuckets: number = 100 +) { const duration = moment.duration(end - start, 'ms'); - const bucketSize = Math.max(calculateAuto.near(100, duration).asSeconds(), 1); + const bucketSize = Math.max( + calculateAuto.near(numBuckets, duration).asSeconds(), + 1 + ); const intervalString = `${bucketSize}s`; - const matches = interval && interval.match(/^([\d]+)([shmdwMy]|ms)$/); - const minBucketSize = matches - ? Number(matches[1]) * unitToSeconds(matches[2]) - : 0; - if (bucketSize < minBucketSize) { + if (bucketSize < 0) { return { - bucketSize: minBucketSize, - intervalString: interval, + bucketSize: 0, + intervalString: 'auto', }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts index 9f5b5cdf47552..ea018868f9517 100644 --- a/x-pack/plugins/apm/server/lib/helpers/metrics.ts +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.ts @@ -11,7 +11,7 @@ export function getMetricsDateHistogramParams( end: number, metricsInterval: number ) { - const { bucketSize } = getBucketSize(start, end, 'auto'); + const { bucketSize } = getBucketSize(start, end); return { field: '@timestamp', diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 6b69e57389dff..eba75433a5148 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -5,6 +5,7 @@ */ import moment from 'moment'; +import { isActivePlatinumLicense } from '../../../common/service_map'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { KibanaRequest } from '../../../../../../src/core/server'; import { APMConfig } from '../..'; @@ -98,11 +99,14 @@ export async function setupRequest( context, request, }), - ml: getMlSetup( - context.plugins.ml, - context.core.savedObjects.client, - request - ), + ml: + context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + ? getMlSetup( + context.plugins.ml, + context.core.savedObjects.client, + request + ) + : undefined, config, }; @@ -115,14 +119,10 @@ export async function setupRequest( } function getMlSetup( - ml: APMRequestHandlerContext['plugins']['ml'], + ml: Required['ml'], savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'], request: KibanaRequest ) { - if (!ml) { - return; - } - return { mlSystem: ml.mlSystemProvider(request), anomalyDetectors: ml.anomalyDetectorsProvider(request), diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index 551384da2cca7..d7e64bdcacd12 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -44,7 +44,7 @@ export async function fetchAndTransformGcMetrics({ }) { const { start, end, apmEventClient, config } = setup; - const { bucketSize } = getBucketSize(start, end, 'auto'); + const { bucketSize } = getBucketSize(start, end); const projection = getMetricsProjection({ setup, @@ -74,7 +74,7 @@ export async function fetchAndTransformGcMetrics({ field: `${LABEL_NAME}`, }, aggs: { - over_time: { + timeseries: { date_histogram: getMetricsDateHistogramParams( start, end, @@ -123,7 +123,7 @@ export async function fetchAndTransformGcMetrics({ const series = aggregations.per_pool.buckets.map((poolBucket, i) => { const label = poolBucket.key as string; - const timeseriesData = poolBucket.over_time; + const timeseriesData = poolBucket.timeseries; const data = timeseriesData.buckets.map((bucket) => { // derivative/value will be undefined for the first hit and if the `max` value is null diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index ec274d20b6005..ed8ae923e6e6c 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from 'kibana/server'; import Boom from 'boom'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseReturnType } from '../../../typings/common'; @@ -27,11 +26,9 @@ export type ServiceAnomaliesResponse = PromiseReturnType< export async function getServiceAnomalies({ setup, - logger, environment, }: { setup: Setup & SetupTimeRange; - logger: Logger; environment?: string; }) { const { ml, start, end } = setup; @@ -41,11 +38,20 @@ export async function getServiceAnomalies({ } const mlCapabilities = await ml.mlSystem.mlCapabilities(); + if (!mlCapabilities.mlFeatureEnabledInSpace) { throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); } const mlJobIds = await getMLJobIds(ml.anomalyDetectors, environment); + + if (!mlJobIds.length) { + return { + mlJobIds: [], + serviceAnomalies: {}, + }; + } + const params = { body: { size: 0, @@ -120,7 +126,9 @@ interface ServiceAnomaliesAggResponse { function transformResponseToServiceAnomalies( response: ServiceAnomaliesAggResponse ): Record { - const serviceAnomaliesMap = response.aggregations.services.buckets.reduce( + const serviceAnomaliesMap = ( + response.aggregations?.services.buckets ?? [] + ).reduce( (statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => { return { ...statsByServiceName, @@ -153,7 +161,7 @@ export async function getMLJobIds( (job) => job.custom_settings?.job_tags?.environment === environment ); if (!matchingMLJob) { - throw new Error(`ML job Not Found for environment "${environment}".`); + return []; } return [matchingMLJob.job_id]; } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index d1c99d778c8f0..1e26b6f3f58f9 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -58,6 +58,9 @@ describe('getServiceMapServiceNodeInfo', () => { indices: {}, start: 1593460053026000, end: 1593497863217000, + config: { + 'xpack.apm.metricsInterval': 30, + }, } as unknown) as Setup & SetupTimeRange; const environment = 'test environment'; const serviceName = 'test service name'; diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index ca86c1d93fa6e..c5e072e073992 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -105,6 +105,24 @@ Array [ "field": "transaction.duration.us", }, }, + "timeseries": Object { + "aggs": Object { + "average": Object { + "avg": Object { + "field": "transaction.duration.us", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "43200s", + "min_doc_count": 0, + }, + }, }, "terms": Object { "field": "service.name", @@ -194,6 +212,19 @@ Array [ "body": Object { "aggs": Object { "services": Object { + "aggs": Object { + "timeseries": Object { + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "43200s", + "min_doc_count": 0, + }, + }, + }, "terms": Object { "field": "service.name", "size": 500, @@ -226,12 +257,37 @@ Array [ Object { "apm": Object { "events": Array [ - "error", + "transaction", ], }, "body": Object { "aggs": Object { "services": Object { + "aggs": Object { + "outcomes": Object { + "terms": Object { + "field": "event.outcome", + }, + }, + "timeseries": Object { + "aggs": Object { + "outcomes": Object { + "terms": Object { + "field": "event.outcome", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "43200s", + "min_doc_count": 0, + }, + }, + }, "terms": Object { "field": "service.name", "size": 500, @@ -255,6 +311,14 @@ Array [ "my.custom.ui.filter": "foo-bar", }, }, + Object { + "terms": Object { + "event.outcome": Array [ + "failure", + "success", + ], + }, + }, ], }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts index d888b43b63fac..50a968467fb4b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -15,15 +15,22 @@ import { getTransactionDurationAverages, getAgentNames, getTransactionRates, - getErrorRates, + getTransactionErrorRates, getEnvironments, + getHealthStatuses, } from './get_services_items_stats'; export type ServiceListAPIResponse = PromiseReturnType; export type ServicesItemsSetup = Setup & SetupTimeRange & SetupUIFilters; export type ServicesItemsProjection = ReturnType; -export async function getServicesItems(setup: ServicesItemsSetup) { +export async function getServicesItems({ + setup, + mlAnomaliesEnvironment, +}: { + setup: ServicesItemsSetup; + mlAnomaliesEnvironment?: string; +}) { const params = { projection: getServicesProjection({ setup }), setup, @@ -33,22 +40,25 @@ export async function getServicesItems(setup: ServicesItemsSetup) { transactionDurationAverages, agentNames, transactionRates, - errorRates, + transactionErrorRates, environments, + healthStatuses, ] = await Promise.all([ getTransactionDurationAverages(params), getAgentNames(params), getTransactionRates(params), - getErrorRates(params), + getTransactionErrorRates(params), getEnvironments(params), + getHealthStatuses(params, mlAnomaliesEnvironment), ]); const allMetrics = [ ...transactionDurationAverages, ...agentNames, ...transactionRates, - ...errorRates, + ...transactionErrorRates, ...environments, + ...healthStatuses, ]; return joinByKey(allMetrics, 'serviceName'); diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index ddce3b667a603..ab6b61ca21746 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EventOutcome } from '../../../../common/event_outcome'; +import { getSeverity } from '../../../../common/anomaly_detection'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { TRANSACTION_DURATION, AGENT_NAME, SERVICE_ENVIRONMENT, + EVENT_OUTCOME, } from '../../../../common/elasticsearch_fieldnames'; import { mergeProjection } from '../../../projections/util/merge_projection'; import { ProcessorEvent } from '../../../../common/processor_event'; @@ -15,6 +19,21 @@ import { ServicesItemsSetup, ServicesItemsProjection, } from './get_services_items'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { + getMLJobIds, + getServiceAnomalies, +} from '../../service_map/get_service_anomalies'; +import { AggregationResultOf } from '../../../../typings/elasticsearch/aggregations'; + +function getDateHistogramOpts(start: number, end: number) { + return { + field: '@timestamp', + fixed_interval: getBucketSize(start, end, 20).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }; +} const MAX_NUMBER_OF_SERVICES = 500; @@ -30,7 +49,7 @@ export const getTransactionDurationAverages = async ({ setup, projection, }: AggregationParams) => { - const { apmEventClient } = setup; + const { apmEventClient, start, end } = setup; const response = await apmEventClient.search( mergeProjection(projection, { @@ -51,6 +70,16 @@ export const getTransactionDurationAverages = async ({ field: TRANSACTION_DURATION, }, }, + timeseries: { + date_histogram: getDateHistogramOpts(start, end), + aggs: { + average: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, }, }, }, @@ -64,9 +93,15 @@ export const getTransactionDurationAverages = async ({ return []; } - return aggregations.services.buckets.map((bucket) => ({ - serviceName: bucket.key as string, - avgResponseTime: bucket.average.value, + return aggregations.services.buckets.map((serviceBucket) => ({ + serviceName: serviceBucket.key as string, + avgResponseTime: { + value: serviceBucket.average.value, + timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.average.value, + })), + }, })); }; @@ -112,9 +147,10 @@ export const getAgentNames = async ({ return []; } - return aggregations.services.buckets.map((bucket) => ({ - serviceName: bucket.key as string, - agentName: bucket.agent_name.hits.hits[0]?._source.agent.name, + return aggregations.services.buckets.map((serviceBucket) => ({ + serviceName: serviceBucket.key as string, + agentName: serviceBucket.agent_name.hits.hits[0]?._source.agent + .name as AgentName, })); }; @@ -122,7 +158,7 @@ export const getTransactionRates = async ({ setup, projection, }: AggregationParams) => { - const { apmEventClient } = setup; + const { apmEventClient, start, end } = setup; const response = await apmEventClient.search( mergeProjection(projection, { apm: { @@ -136,6 +172,11 @@ export const getTransactionRates = async ({ ...projection.body.aggs.services.terms, size: MAX_NUMBER_OF_SERVICES, }, + aggs: { + timeseries: { + date_histogram: getDateHistogramOpts(start, end), + }, + }, }, }, }, @@ -150,33 +191,67 @@ export const getTransactionRates = async ({ const deltaAsMinutes = getDeltaAsMinutes(setup); - return aggregations.services.buckets.map((bucket) => { - const transactionsPerMinute = bucket.doc_count / deltaAsMinutes; + return aggregations.services.buckets.map((serviceBucket) => { + const transactionsPerMinute = serviceBucket.doc_count / deltaAsMinutes; return { - serviceName: bucket.key as string, - transactionsPerMinute, + serviceName: serviceBucket.key as string, + transactionsPerMinute: { + value: transactionsPerMinute, + timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count / deltaAsMinutes, + })), + }, }; }); }; -export const getErrorRates = async ({ +export const getTransactionErrorRates = async ({ setup, projection, }: AggregationParams) => { - const { apmEventClient } = setup; + const { apmEventClient, start, end } = setup; + + const outcomes = { + terms: { + field: EVENT_OUTCOME, + }, + }; + const response = await apmEventClient.search( mergeProjection(projection, { apm: { - events: [ProcessorEvent.error], + events: [ProcessorEvent.transaction], }, body: { size: 0, + query: { + bool: { + filter: [ + ...projection.body.query.bool.filter, + { + terms: { + [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success], + }, + }, + ], + }, + }, aggs: { services: { terms: { ...projection.body.aggs.services.terms, size: MAX_NUMBER_OF_SERVICES, }, + aggs: { + outcomes, + timeseries: { + date_histogram: getDateHistogramOpts(start, end), + aggs: { + outcomes, + }, + }, + }, }, }, }, @@ -189,13 +264,36 @@ export const getErrorRates = async ({ return []; } - const deltaAsMinutes = getDeltaAsMinutes(setup); + function calculateTransactionErrorPercentage( + outcomeResponse: AggregationResultOf + ) { + const successfulTransactions = + outcomeResponse.buckets.find( + (bucket) => bucket.key === EventOutcome.success + )?.doc_count ?? 0; + const failedTransactions = + outcomeResponse.buckets.find( + (bucket) => bucket.key === EventOutcome.failure + )?.doc_count ?? 0; - return aggregations.services.buckets.map((bucket) => { - const errorsPerMinute = bucket.doc_count / deltaAsMinutes; + return failedTransactions / (successfulTransactions + failedTransactions); + } + + return aggregations.services.buckets.map((serviceBucket) => { + const transactionErrorRate = calculateTransactionErrorPercentage( + serviceBucket.outcomes + ); return { - serviceName: bucket.key as string, - errorsPerMinute, + serviceName: serviceBucket.key as string, + transactionErrorRate: { + value: transactionErrorRate, + timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => { + return { + x: dateBucket.key, + y: calculateTransactionErrorPercentage(dateBucket.outcomes), + }; + }), + }, }; }); }; @@ -241,8 +339,43 @@ export const getEnvironments = async ({ return []; } - return aggregations.services.buckets.map((bucket) => ({ - serviceName: bucket.key as string, - environments: bucket.environments.buckets.map((env) => env.key as string), + return aggregations.services.buckets.map((serviceBucket) => ({ + serviceName: serviceBucket.key as string, + environments: serviceBucket.environments.buckets.map( + (envBucket) => envBucket.key as string + ), })); }; + +export const getHealthStatuses = async ( + { setup }: AggregationParams, + mlAnomaliesEnvironment?: string +) => { + if (!setup.ml) { + return []; + } + + const jobIds = await getMLJobIds( + setup.ml.anomalyDetectors, + mlAnomaliesEnvironment + ); + if (!jobIds.length) { + return []; + } + + const anomalies = await getServiceAnomalies({ + setup, + environment: mlAnomaliesEnvironment, + }); + + return Object.keys(anomalies.serviceAnomalies).map((serviceName) => { + const stats = anomalies.serviceAnomalies[serviceName]; + + const severity = getSeverity(stats.anomalyScore); + + return { + serviceName, + severity, + }; + }); +}; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/index.ts b/x-pack/plugins/apm/server/lib/services/get_services/index.ts index 5a909ebd6ec54..28b4c64a4af47 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/index.ts @@ -17,11 +17,15 @@ import { getServicesItems } from './get_services_items'; export type ServiceListAPIResponse = PromiseReturnType; -export async function getServices( - setup: Setup & SetupTimeRange & SetupUIFilters -) { +export async function getServices({ + setup, + mlAnomaliesEnvironment, +}: { + setup: Setup & SetupTimeRange & SetupUIFilters; + mlAnomaliesEnvironment?: string; +}) { const [items, hasLegacyData] = await Promise.all([ - getServicesItems(setup), + getServicesItems({ setup, mlAnomaliesEnvironment }), getLegacyDataStatus(setup), ]); diff --git a/x-pack/plugins/apm/server/lib/services/queries.test.ts b/x-pack/plugins/apm/server/lib/services/queries.test.ts index 99c58a17d396a..9b0dd7a03ca5b 100644 --- a/x-pack/plugins/apm/server/lib/services/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/services/queries.test.ts @@ -38,7 +38,7 @@ describe('services queries', () => { }); it('fetches the service items', async () => { - mock = await inspectSearchParams((setup) => getServicesItems(setup)); + mock = await inspectSearchParams((setup) => getServicesItems({ setup })); const allParams = mock.spy.mock.calls.map((call) => call[0]); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index f7b7f72168160..1e08b04416e17 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -62,7 +62,7 @@ export async function getErrorRate({ total_transactions: { date_histogram: { field: '@timestamp', - fixed_interval: getBucketSize(start, end, 'auto').intervalString, + fixed_interval: getBucketSize(start, end).intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts index f68082dfaa1e1..51118278fb824 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts @@ -24,7 +24,7 @@ export type ESResponse = PromiseReturnType; export function fetcher(options: Options) { const { end, apmEventClient, start, uiFiltersES } = options.setup; const { serviceName, transactionName } = options; - const { intervalString } = getBucketSize(start, end, 'auto'); + const { intervalString } = getBucketSize(start, end); const transactionNameFilter = transactionName ? [{ term: { [TRANSACTION_NAME]: transactionName } }] diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts index 596c3137ec19f..d8865f0049d35 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts @@ -64,16 +64,10 @@ export async function getAnomalySeries({ return; } - let mlJobIds: string[] = []; - try { - mlJobIds = await getMLJobIds( - setup.ml.anomalyDetectors, - uiFilters.environment - ); - } catch (error) { - logger.error(error); - return; - } + const mlJobIds = await getMLJobIds( + setup.ml.anomalyDetectors, + uiFilters.environment + ); // don't fetch anomalies if there are isn't exaclty 1 ML job match for the given environment if (mlJobIds.length !== 1) { @@ -87,7 +81,7 @@ export async function getAnomalySeries({ } const { start, end } = setup; - const { intervalString, bucketSize } = getBucketSize(start, end, 'auto'); + const { intervalString, bucketSize } = getBucketSize(start, end); const esResponse = await anomalySeriesFetcher({ serviceName, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index 1498c22e327d6..f39529b59caa6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -35,7 +35,7 @@ export function timeseriesFetcher({ setup: Setup & SetupTimeRange & SetupUIFilters; }) { const { start, end, uiFiltersES, apmEventClient } = setup; - const { intervalString } = getBucketSize(start, end, 'auto'); + const { intervalString } = getBucketSize(start, end); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts index 8a0fe1a57736f..ea06bd57bfff2 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts @@ -20,7 +20,7 @@ export async function getApmTimeseriesData(options: { setup: Setup & SetupTimeRange & SetupUIFilters; }) { const { start, end } = options.setup; - const { bucketSize } = getBucketSize(start, end, 'auto'); + const { bucketSize } = getBucketSize(start, end); const durationAsMinutes = (end - start) / 1000 / 60; const timeseriesResponse = await timeseriesFetcher(options); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 971e247d98986..8533d54ed6277 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -8,7 +8,7 @@ import Boom from 'boom'; import * as t from 'io-ts'; import { invalidLicenseMessage, - isValidPlatinumLicense, + isActivePlatinumLicense, } from '../../common/service_map'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; @@ -33,7 +33,7 @@ export const serviceMapRoute = createRoute(() => ({ if (!context.config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } - if (!isValidPlatinumLicense(context.licensing.license)) { + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); @@ -59,7 +59,7 @@ export const serviceMapServiceNodeRoute = createRoute(() => ({ if (!context.config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } - if (!isValidPlatinumLicense(context.licensing.license)) { + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } const logger = context.logger; diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 74ab717b8de59..cc7f25867df2c 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -16,6 +16,7 @@ import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; +import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters'; export const servicesRoute = createRoute(() => ({ path: '/api/apm/services', @@ -23,8 +24,17 @@ export const servicesRoute = createRoute(() => ({ query: t.intersection([uiFiltersRt, rangeRt]), }, handler: async ({ context, request }) => { + const { environment } = getParsedUiFilters({ + uiFilters: context.params.query.uiFilters, + logger: context.logger, + }); + const setup = await setupRequest(context, request); - const services = await getServices(setup); + + const services = await getServices({ + setup, + mlAnomaliesEnvironment: environment, + }); return services; }, diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index ac25f22751f2f..290e81bd29973 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -6,6 +6,7 @@ import * as t from 'io-ts'; import Boom from 'boom'; +import { isActivePlatinumLicense } from '../../../common/service_map'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { createRoute } from '../create_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; @@ -24,8 +25,7 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const license = context.licensing.license; - if (!license.isActive || !license.hasAtLeast('platinum')) { + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } @@ -56,8 +56,7 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({ const { environments } = context.params.body; const setup = await setupRequest(context, request); - const license = context.licensing.license; - if (!license.isActive || !license.hasAtLeast('platinum')) { + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 7a7592b248960..bbd2c9eb86249 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -346,6 +346,12 @@ export type ValidAggregationKeysOf< T extends Record > = keyof (UnionToIntersection extends never ? T : UnionToIntersection); +export type AggregationResultOf< + TAggregationOptionsMap extends AggregationOptionsMap, + TDocument +> = AggregationResponsePart[AggregationType & + ValidAggregationKeysOf]; + export type AggregationResponseMap< TAggregationInputMap extends AggregationInputMap | undefined, TDocument diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 13f7159ba6043..b5bfe3eec7d35 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; -import { useContext } from 'react'; -import { ThemeContext } from 'styled-components'; +import { useTheme } from './use_theme'; export function useChartTheme() { - const theme = useContext(ThemeContext); + const theme = useTheme(); return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; } diff --git a/x-pack/plugins/observability/public/hooks/use_theme.tsx b/x-pack/plugins/observability/public/hooks/use_theme.tsx new file mode 100644 index 0000000000000..d0449a4432d93 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_theme.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useContext } from 'react'; +import { ThemeContext } from 'styled-components'; +import { EuiTheme } from '../../../../legacy/common/eui_styled_components'; + +export function useTheme() { + const theme: EuiTheme = useContext(ThemeContext); + return theme; +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 03939736b64ae..0aecea59ad013 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -26,3 +26,6 @@ export { } from './hooks/use_track_metric'; export * from './typings'; + +export { useChartTheme } from './hooks/use_chart_theme'; +export { useTheme } from './hooks/use_theme'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cb4a0f226f24c..70e7ed3f5b784 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4858,12 +4858,9 @@ "xpack.apm.serviceOverview.upgradeAssistantLink": "アップグレードアシスタント", "xpack.apm.servicesTable.7xOldDataMessage": "また、移行が必要な古いデータがある可能性もあります。", "xpack.apm.servicesTable.7xUpgradeServerMessage": "バージョン7.xより前からのアップグレードですか?また、\n APMサーバーインスタンスを7.0以降にアップグレードしていることも確認してください。", - "xpack.apm.servicesTable.agentColumnLabel": "エージェント", "xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均応答時間", "xpack.apm.servicesTable.environmentColumnLabel": "環境", "xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 個の環境} other {# 個の環境}}", - "xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "1 分あたりのエラー", - "xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "エラー", "xpack.apm.servicesTable.nameColumnLabel": "名前", "xpack.apm.servicesTable.noServicesLabel": "APM サービスがインストールされていないようです。追加しましょう!", "xpack.apm.servicesTable.notFoundLabel": "サービスが見つかりません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a8381a8f142c8..861579e439d8d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4861,12 +4861,9 @@ "xpack.apm.serviceOverview.upgradeAssistantLink": "升级助手", "xpack.apm.servicesTable.7xOldDataMessage": "可能还有需要迁移的旧数据。", "xpack.apm.servicesTable.7xUpgradeServerMessage": "从 7.x 之前的版本升级?另外,确保您已将\n APM Server 实例升级到至少 7.0。", - "xpack.apm.servicesTable.agentColumnLabel": "代理", "xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均响应时间", "xpack.apm.servicesTable.environmentColumnLabel": "环境", "xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 个环境} other {# 个环境}}", - "xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "每分钟错误数", - "xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "错误", "xpack.apm.servicesTable.nameColumnLabel": "名称", "xpack.apm.servicesTable.noServicesLabel": "似乎您没有安装任何 APM 服务。让我们添加一些!", "xpack.apm.servicesTable.notFoundLabel": "未找到任何服务", diff --git a/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts b/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts index e4cceca573ce8..a87d080e564a2 100644 --- a/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts +++ b/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts @@ -12,7 +12,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const range = archives['apm_8.0.0']; + const archiveName = 'apm_8.0.0'; + const range = archives[archiveName]; const start = encodeURIComponent(range.start); const end = encodeURIComponent(range.end); @@ -29,8 +30,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); describe('when data is loaded', () => { - before(() => esArchiver.load('apm_8.0.0')); - after(() => esArchiver.unload('apm_8.0.0')); + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); it('returns the agent name', async () => { const response = await supertest.get( diff --git a/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts b/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts index 8d91f4542e454..116b2987db32a 100644 --- a/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts @@ -4,18 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sortBy } from 'lodash'; import expect from '@kbn/expect'; +import { isEmpty, pick } from 'lodash'; +import { PromiseReturnType } from '../../../../../plugins/apm/typings/common'; import { expectSnapshot } from '../../../common/match_snapshot'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import archives_metadata from '../../../common/archives_metadata'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const archiveName = 'apm_8.0.0'; + + const range = archives_metadata[archiveName]; + // url parameters - const start = encodeURIComponent('2020-06-29T06:45:00.000Z'); - const end = encodeURIComponent('2020-06-29T06:49:00.000Z'); + const start = encodeURIComponent(range.start); + const end = encodeURIComponent(range.end); + const uiFilters = encodeURIComponent(JSON.stringify({})); describe('APM Services Overview', () => { @@ -31,52 +38,189 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); describe('when data is loaded', () => { - before(() => esArchiver.load('8.0.0')); - after(() => esArchiver.unload('8.0.0')); + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); - it('returns a list of services', async () => { - const response = await supertest.get( - `/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}` - ); - // sort services to mitigate unstable sort order - const services = sortBy(response.body.items, ['serviceName']); + describe('and fetching a list of services', () => { + let response: PromiseReturnType; + before(async () => { + response = await supertest.get( + `/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}` + ); + }); - expect(response.status).to.be(200); - expectSnapshot(services).toMatchInline(` - Array [ - Object { - "agentName": "rum-js", - "avgResponseTime": 116375, - "environments": Array [], - "errorsPerMinute": 2.75, - "serviceName": "client", - "transactionsPerMinute": 2, - }, - Object { - "agentName": "java", - "avgResponseTime": 25636.349593495936, - "environments": Array [ + it('the response is successful', () => { + expect(response.status).to.eql(200); + }); + + it('returns hasHistoricalData: true', () => { + expect(response.body.hasHistoricalData).to.be(true); + }); + + it('returns hasLegacyData: false', () => { + expect(response.body.hasLegacyData).to.be(false); + }); + + it('returns the correct service names', () => { + expectSnapshot(response.body.items.map((item: any) => item.serviceName)).toMatchInline(` + Array [ + "opbeans-python", + "opbeans-node", + "opbeans-ruby", + "opbeans-go", + "opbeans-dotnet", + "opbeans-java", + "opbeans-rum", + ] + `); + }); + + it('returns the correct metrics averages', () => { + expectSnapshot( + response.body.items.map((item: any) => + pick( + item, + 'transactionErrorRate.value', + 'avgResponseTime.value', + 'transactionsPerMinute.value' + ) + ) + ).toMatchInline(` + Array [ + Object { + "avgResponseTime": Object { + "value": 208079.9121184089, + }, + "transactionErrorRate": Object { + "value": 0.041666666666666664, + }, + "transactionsPerMinute": Object { + "value": 18.016666666666666, + }, + }, + Object { + "avgResponseTime": Object { + "value": 578297.1431623931, + }, + "transactionErrorRate": Object { + "value": 0.03317535545023697, + }, + "transactionsPerMinute": Object { + "value": 7.8, + }, + }, + Object { + "avgResponseTime": Object { + "value": 60518.587926509186, + }, + "transactionErrorRate": Object { + "value": 0.013123359580052493, + }, + "transactionsPerMinute": Object { + "value": 6.35, + }, + }, + Object { + "avgResponseTime": Object { + "value": 25259.78717201166, + }, + "transactionErrorRate": Object { + "value": 0.014577259475218658, + }, + "transactionsPerMinute": Object { + "value": 5.716666666666667, + }, + }, + Object { + "avgResponseTime": Object { + "value": 527290.3218390804, + }, + "transactionErrorRate": Object { + "value": 0.01532567049808429, + }, + "transactionsPerMinute": Object { + "value": 4.35, + }, + }, + Object { + "avgResponseTime": Object { + "value": 530245.8571428572, + }, + "transactionErrorRate": Object { + "value": 0.15384615384615385, + }, + "transactionsPerMinute": Object { + "value": 3.033333333333333, + }, + }, + Object { + "avgResponseTime": Object { + "value": 896134.328358209, + }, + "transactionsPerMinute": Object { + "value": 2.2333333333333334, + }, + }, + ] + `); + }); + + it('returns environments', () => { + expectSnapshot(response.body.items.map((item: any) => item.environments ?? [])) + .toMatchInline(` + Array [ + Array [ "production", ], - "errorsPerMinute": 4.5, - "serviceName": "opbeans-java", - "transactionsPerMinute": 30.75, - }, - Object { - "agentName": "nodejs", - "avgResponseTime": 38682.52419354839, - "environments": Array [ + Array [ + "testing", + ], + Array [ "production", ], - "errorsPerMinute": 3.75, - "serviceName": "opbeans-node", - "transactionsPerMinute": 31, - }, - ] - `); - - expect(response.body.hasHistoricalData).to.be(true); - expect(response.body.hasLegacyData).to.be(false); + Array [ + "testing", + ], + Array [ + "production", + ], + Array [ + "production", + ], + Array [ + "testing", + ], + ] + `); + }); + + it(`RUM services don't report any transaction error rates`, () => { + // RUM transactions don't have event.outcome set, + // so they should not have an error rate + + const rumServices = response.body.items.filter( + (item: any) => item.agentName === 'rum-js' + ); + + expect(rumServices.length).to.be.greaterThan(0); + + expect(rumServices.every((item: any) => isEmpty(item.transactionErrorRate?.value))); + }); + + it('non-RUM services all report transaction error rates', () => { + const nonRumServices = response.body.items.filter( + (item: any) => item.agentName !== 'rum-js' + ); + + expect( + nonRumServices.every((item: any) => { + return ( + typeof item.transactionErrorRate?.value === 'number' && + item.transactionErrorRate.timeseries.length > 0 + ); + }) + ).to.be(true); + }); }); }); }); diff --git a/x-pack/test/apm_api_integration/trial/tests/index.ts b/x-pack/test/apm_api_integration/trial/tests/index.ts index 48ffa13012696..c5ca086b5f370 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -16,6 +16,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr describe('Services', function () { loadTestFile(require.resolve('./services/annotations')); loadTestFile(require.resolve('./services/rum_services.ts')); + loadTestFile(require.resolve('./services/top_services.ts')); }); describe('Settings', function () { diff --git a/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts new file mode 100644 index 0000000000000..76af02ec1606e --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { expectSnapshot } from '../../../common/match_snapshot'; +import { PromiseReturnType } from '../../../../../plugins/apm/typings/common'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import archives_metadata from '../../../common/archives_metadata'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const archiveName = 'apm_8.0.0'; + + const range = archives_metadata[archiveName]; + + // url parameters + const start = encodeURIComponent(range.start); + const end = encodeURIComponent(range.end); + + const uiFilters = encodeURIComponent(JSON.stringify({})); + + describe('APM Services Overview', () => { + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + describe('and fetching a list of services', () => { + let response: PromiseReturnType; + before(async () => { + response = await supertest.get( + `/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}` + ); + }); + + it('the response is successful', () => { + expect(response.status).to.eql(200); + }); + + it('there is at least one service', () => { + expect(response.body.items.length).to.be.greaterThan(0); + }); + + it('some items have severity set', () => { + // Under the assumption that the loaded archive has + // at least one APM ML job, and the time range is longer + // than 15m, at least one items should have severity set. + // Note that we currently have a bug where healthy services + // report as unknown (so without any severity status): + // https://github.com/elastic/kibana/issues/77083 + + const severityScores = response.body.items.map((item: any) => item.severity); + + expect(severityScores.filter(Boolean).length).to.be.greaterThan(0); + + expectSnapshot(severityScores).toMatchInline(` + Array [ + undefined, + undefined, + undefined, + undefined, + undefined, + "warning", + undefined, + ] + `); + }); + }); + }); + }); +} From 9dfa20acd0cc57c0a8f0c418111106899e03bd1e Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Mon, 14 Sep 2020 17:14:57 +0300 Subject: [PATCH 03/91] [Telemetry Tools] update lodash to 4.17 (#77317) --- packages/kbn-telemetry-tools/package.json | 4 +- .../src/tools/serializer.ts | 4 +- .../kbn-telemetry-tools/src/tools/utils.ts | 80 +++++++++++-------- yarn.lock | 10 --- 4 files changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/kbn-telemetry-tools/package.json b/packages/kbn-telemetry-tools/package.json index 63a8fcf30335e..4318cbcf2ec4e 100644 --- a/packages/kbn-telemetry-tools/package.json +++ b/packages/kbn-telemetry-tools/package.json @@ -10,12 +10,12 @@ "kbn:watch": "yarn build --watch" }, "devDependencies": { - "lodash": "npm:@elastic/lodash@3.10.1-kibana4", + "lodash": "^4.17.20", "@kbn/dev-utils": "1.0.0", "@kbn/utility-types": "1.0.0", "@types/normalize-path": "^3.0.0", "normalize-path": "^3.0.0", - "@types/lodash": "^3.10.1", + "@types/lodash": "^4.14.159", "moment": "^2.24.0", "typescript": "4.0.2" } diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.ts b/packages/kbn-telemetry-tools/src/tools/serializer.ts index d5412f64f3615..7afe828298b4b 100644 --- a/packages/kbn-telemetry-tools/src/tools/serializer.ts +++ b/packages/kbn-telemetry-tools/src/tools/serializer.ts @@ -18,7 +18,7 @@ */ import * as ts from 'typescript'; -import { uniq } from 'lodash'; +import { uniqBy } from 'lodash'; import { getResolvedModuleSourceFile, getIdentifierDeclarationFromSource, @@ -148,7 +148,7 @@ export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor | .map((typeNode) => getDescriptor(typeNode, program)) .filter(discardNullOrUndefined); - const uniqueKinds = uniq(kinds, 'kind'); + const uniqueKinds = uniqBy(kinds, 'kind'); if (uniqueKinds.length !== 1) { throw Error('Mapping does not support conflicting union types.'); diff --git a/packages/kbn-telemetry-tools/src/tools/utils.ts b/packages/kbn-telemetry-tools/src/tools/utils.ts index c1424785b22a5..3d6764117374c 100644 --- a/packages/kbn-telemetry-tools/src/tools/utils.ts +++ b/packages/kbn-telemetry-tools/src/tools/utils.ts @@ -18,7 +18,18 @@ */ import * as ts from 'typescript'; -import { pick, isObject, each, isArray, reduce, isEmpty, merge, transform, isEqual } from 'lodash'; +import { + pick, + pickBy, + isObject, + forEach, + isArray, + reduce, + isEmpty, + merge, + transform, + isEqual, +} from 'lodash'; import * as path from 'path'; import glob from 'glob'; import { readFile, writeFile } from 'fs'; @@ -186,17 +197,17 @@ export function getPropertyValue( } } -export function pickDeep(collection: any, identity: any, thisArg?: any) { - const picked: any = pick(collection, identity, thisArg); - const collections = pick(collection, isObject, thisArg); +export function pickDeep(collection: any, identity: any) { + const picked: any = pick(collection, identity); + const collections = pickBy(collection, isObject); - each(collections, function (item, key) { + forEach(collections, function (item, key) { let object; if (isArray(item)) { object = reduce( item, function (result, value) { - const pickedDeep = pickDeep(value, identity, thisArg); + const pickedDeep = pickDeep(value, identity); if (!isEmpty(pickedDeep)) { result.push(pickedDeep); } @@ -205,7 +216,7 @@ export function pickDeep(collection: any, identity: any, thisArg?: any) { [] as any[] ); } else { - object = pickDeep(item, identity, thisArg); + object = pickDeep(item, identity); } if (!isEmpty(object)) { @@ -230,33 +241,38 @@ export const flattenKeys = (obj: any, keyPath: any[] = []): any => { return { [keyPath.join('.')]: obj }; }; +type ObjectDict = Record; export function difference(actual: any, expected: any) { - function changes(obj: { [key: string]: any }, base: { [key: string]: any }) { - return transform(obj, function (result, value, key) { - if (key && /@@INDEX@@/.test(`${key}`)) { - // The type definition is an Index Signature, fuzzy searching for similar keys - const regexp = new RegExp(`${key}`.replace(/@@INDEX@@/g, '(.+)?')); - const keysInBase = Object.keys(base) - .map((k) => { - const match = k.match(regexp); - return match && match[0]; - }) - .filter((s): s is string => !!s); - - if (keysInBase.length === 0) { - // Mark this key as wrong because we couldn't find any matching keys - result[key] = value; - } - - keysInBase.forEach((k) => { - if (!isEqual(value, base[k])) { - result[k] = isObject(value) && isObject(base[k]) ? changes(value, base[k]) : value; + function changes(obj: ObjectDict, base: ObjectDict) { + return transform( + obj, + function (result, value, key) { + if (key && /@@INDEX@@/.test(`${key}`)) { + // The type definition is an Index Signature, fuzzy searching for similar keys + const regexp = new RegExp(`${key}`.replace(/@@INDEX@@/g, '(.+)?')); + const keysInBase = Object.keys(base) + .map((k) => { + const match = k.match(regexp); + return match && match[0]; + }) + .filter((s): s is string => !!s); + + if (keysInBase.length === 0) { + // Mark this key as wrong because we couldn't find any matching keys + result[key] = value; } - }); - } else if (key && !isEqual(value, base[key])) { - result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value; - } - }); + + keysInBase.forEach((k) => { + if (!isEqual(value, base[k])) { + result[k] = isObject(value) && isObject(base[k]) ? changes(value, base[k]) : value; + } + }); + } else if (key && !isEqual(value, base[key])) { + result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value; + } + }, + {} as ObjectDict + ); } return changes(actual, expected); } diff --git a/yarn.lock b/yarn.lock index 66be8ad4490b8..ddecaf17f7bcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4123,11 +4123,6 @@ "@types/node" "*" "@types/webpack" "*" -"@types/lodash@^3.10.1": - version "3.10.3" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-3.10.3.tgz#aaddec6a3c93bf03b402db3acf5d4c77bce8bdff" - integrity sha512-b9zScBKmB/RJqETbxu3YRya61vJOik89/lR+NdxjZAFMDcMSjwX6IhQoP4terJkhsa9TE1C+l6XwxCkhhsaZXg== - "@types/lodash@^4.14.116", "@types/lodash@^4.14.159": version "4.14.159" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.159.tgz#61089719dc6fdd9c5cb46efc827f2571d1517065" @@ -18971,11 +18966,6 @@ lodash@^3.10.1: resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= -"lodash@npm:@elastic/lodash@3.10.1-kibana4": - version "3.10.1-kibana4" - resolved "https://registry.yarnpkg.com/@elastic/lodash/-/lodash-3.10.1-kibana4.tgz#d491228fd659b4a1b0dfa08ba9c67a4979b9746d" - integrity sha512-geQqXd9ZedRCL+kq5cpeahYWYaYRV0BMXhCwzq4DpnGCVs430FTMS3Wcot3XChZZhCvkwHm15bpNjB312vPxaA== - log-ok@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/log-ok/-/log-ok-0.1.1.tgz#bea3dd36acd0b8a7240d78736b5b97c65444a334" From b18998026b0aa7452ff5f77436110e0d51821cde Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 14 Sep 2020 10:20:18 -0400 Subject: [PATCH 04/91] [Ingest Manager] Fix flyout instruction selection (#77071) --- .../agent_enrollment_flyout/managed_instructions.tsx | 4 ++-- .../agent_enrollment_flyout/standalone_instructions.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index 7db9d72eb50e4..04fef7f4b3f21 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -24,7 +24,7 @@ interface Props { agentPolicies?: AgentPolicy[]; } -export const ManagedInstructions: React.FunctionComponent = ({ agentPolicies }) => { +export const ManagedInstructions = React.memo(({ agentPolicies }) => { const { getHref } = useLink(); const core = useCore(); const fleetStatus = useFleetStatus(); @@ -91,4 +91,4 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentPolic )} ); -}; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index 9262cc2cb42ac..387ccfc66cbc1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -31,7 +31,7 @@ interface Props { const RUN_INSTRUCTIONS = './elastic-agent run'; -export const StandaloneInstructions: React.FunctionComponent = ({ agentPolicies }) => { +export const StandaloneInstructions = React.memo(({ agentPolicies }) => { const { getHref } = useLink(); const core = useCore(); const { notifications } = core; @@ -189,4 +189,4 @@ export const StandaloneInstructions: React.FunctionComponent = ({ agentPo ); -}; +}); From cbcd1ebd328afeb7eeca281a608a64e5a8b69261 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 14 Sep 2020 10:25:59 -0400 Subject: [PATCH 05/91] [Mappings editor] Add support for wildcard field type (#76574) --- .../ignore_above_parameter.tsx | 39 +++++++++++++++++++ .../document_fields/field_parameters/index.ts | 2 + .../fields/field_types/flattened_type.tsx | 26 ++----------- .../fields/field_types/index.ts | 2 + .../fields/field_types/keyword_type.tsx | 21 ++-------- .../fields/field_types/wildcard_type.tsx | 29 ++++++++++++++ .../constants/data_types_definition.tsx | 18 +++++++++ .../mappings_editor/types/document_fields.ts | 1 + .../translations/translations/ja-JP.json | 5 --- .../translations/translations/zh-CN.json | 5 --- 10 files changed, 98 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_above_parameter.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/wildcard_type.tsx diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_above_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_above_parameter.tsx new file mode 100644 index 0000000000000..48a8e42f5065d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_above_parameter.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { documentationService } from '../../../../../services/documentation'; +import { getFieldConfig } from '../../../lib'; +import { UseField, Field } from '../../../shared_imports'; +import { EditFieldFormRow } from '../fields/edit_field'; + +interface Props { + defaultToggleValue: boolean; +} + +export const IgnoreAboveParameter: FunctionComponent = ({ defaultToggleValue }) => ( + + + +); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts index 805a6b6ece705..a2d5c7c8d5308 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts @@ -69,6 +69,8 @@ export * from './other_type_name_parameter'; export * from './other_type_json_parameter'; +export * from './ignore_above_parameter'; + export const PARAMETER_SERIALIZERS = [relationsSerializer, dynamicSerializer]; export const PARAMETER_DESERIALIZERS = [relationsDeserializer, dynamicDeserializer]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx index 7c8ac86f14153..e96426ece27e8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { documentationService } from '../../../../../../services/documentation'; import { NormalizedField, Field as FieldType } from '../../../../types'; import { UseField, Field } from '../../../../shared_imports'; import { getFieldConfig } from '../../../../lib'; @@ -19,6 +18,7 @@ import { NullValueParameter, SimilarityParameter, SplitQueriesOnWhitespaceParameter, + IgnoreAboveParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -29,6 +29,7 @@ interface Props { const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'boost': + case 'ignore_above': case 'similarity': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -66,28 +67,9 @@ export const FlattenedType = React.memo(({ field }: Props) => { - {/* ignore_above */} - - - + /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts index 62aa2d1eb0b3b..d84d9c6ea40cf 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts @@ -29,6 +29,7 @@ import { OtherType } from './other_type'; import { NestedType } from './nested_type'; import { JoinType } from './join_type'; import { RankFeatureType } from './rank_feature_type'; +import { WildcardType } from './wildcard_type'; const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { alias: AliasType, @@ -54,6 +55,7 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { nested: NestedType, join: JoinType, rank_feature: RankFeatureType, + wildcard: WildcardType, }; export const getParametersFormForType = ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx index 43377357f1e6f..dc4f4b3ba5ff1 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx @@ -23,6 +23,7 @@ import { SimilarityParameter, CopyToParameter, SplitQueriesOnWhitespaceParameter, + IgnoreAboveParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -79,25 +80,9 @@ export const KeywordType = ({ field }: Props) => { - {/* ignore_above */} - - - + /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/wildcard_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/wildcard_type.tsx new file mode 100644 index 0000000000000..825b9e17c8d2c --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/wildcard_type.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { getFieldConfig } from '../../../../lib'; +import { IgnoreAboveParameter } from '../../field_parameters'; +import { AdvancedParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const WildcardType = ({ field }: Props) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index edfb6903a8585..a8844c7a9b270 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -784,6 +784,23 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + wildcard: { + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.wildcardDescription', { + defaultMessage: 'Wildcard', + }), + value: 'wildcard', + documentation: { + main: '/keyword.html#wildcard-field-type', + }, + description: () => ( +

+ +

+ ), + }, other: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.otherDescription', { defaultMessage: 'Other', @@ -825,6 +842,7 @@ export const MAIN_TYPES: MainType[] = [ 'shape', 'text', 'token_count', + 'wildcard', 'other', ]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index 131ce08a87ad7..fd0e4ed32bfe8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -59,6 +59,7 @@ export type MainType = | 'geo_point' | 'geo_shape' | 'token_count' + | 'wildcard' /** * 'other' is a special type that only exists inside of MappingsEditor as a placeholder * for undocumented field types. diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 70e7ed3f5b784..3938ed163f6cc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7574,7 +7574,6 @@ "xpack.idxMgmt.mappingsEditor.fielddata.frequencyFilterPercentageFieldLabel": "パーセンテージベースの頻度範囲", "xpack.idxMgmt.mappingsEditor.fielddata.useAbsoluteValuesFieldLabel": "絶対値の使用", "xpack.idxMgmt.mappingsEditor.fieldsTabLabel": "マッピングされたフィールド", - "xpack.idxMgmt.mappingsEditor.flattened.ignoreAboveDocLinkText": "上記ドキュメントの無視", "xpack.idxMgmt.mappingsEditor.formatDocLinkText": "フォーマットのドキュメンテーション", "xpack.idxMgmt.mappingsEditor.formatFieldLabel": "フォーマット", "xpack.idxMgmt.mappingsEditor.formatHelpText": "{dateSyntax}構文を使用し、カスタムフォーマットを指定します。", @@ -7705,10 +7704,6 @@ "xpack.idxMgmt.mappingsEditor.joinType.relationshipTable.parentFieldAriaLabel": "親フィールド", "xpack.idxMgmt.mappingsEditor.joinType.relationshipTable.removeRelationshipTooltipLabel": "関係を削除", "xpack.idxMgmt.mappingsEditor.largestShingleSizeFieldLabel": "最大シングルサイズ", - "xpack.idxMgmt.mappingsEditor.leafLengthLimitFieldDescription": "特定の長さ以上のリーフ値のインデックスを無効化。これは、Luceneの文字制限(8,191 UTF-8 文字)に対する保護に役立ちます。", - "xpack.idxMgmt.mappingsEditor.leafLengthLimitFieldTitle": "長さ制限の設定", - "xpack.idxMgmt.mappingsEditor.lengthLimitFieldDescription": "この値よりも長い文字列はインデックスされません。これは、Luceneの文字制限(8,191 UTF-8 文字)に対する保護に役立ちます。", - "xpack.idxMgmt.mappingsEditor.lengthLimitFieldTitle": "長さ制限の設定", "xpack.idxMgmt.mappingsEditor.loadFromJsonButtonLabel": "JSONの読み込み", "xpack.idxMgmt.mappingsEditor.loadJsonModal.acceptWarningLabel": "読み込みの続行", "xpack.idxMgmt.mappingsEditor.loadJsonModal.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 861579e439d8d..c8eefb45ea9f5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7577,7 +7577,6 @@ "xpack.idxMgmt.mappingsEditor.fielddata.frequencyFilterPercentageFieldLabel": "基于百分比的频率范围", "xpack.idxMgmt.mappingsEditor.fielddata.useAbsoluteValuesFieldLabel": "使用绝对值", "xpack.idxMgmt.mappingsEditor.fieldsTabLabel": "已映射字段", - "xpack.idxMgmt.mappingsEditor.flattened.ignoreAboveDocLinkText": "“忽略上述”文档", "xpack.idxMgmt.mappingsEditor.formatDocLinkText": "“格式”文档", "xpack.idxMgmt.mappingsEditor.formatFieldLabel": "格式", "xpack.idxMgmt.mappingsEditor.formatHelpText": "使用 {dateSyntax} 语法指定定制格式。", @@ -7708,10 +7707,6 @@ "xpack.idxMgmt.mappingsEditor.joinType.relationshipTable.parentFieldAriaLabel": "父项字段", "xpack.idxMgmt.mappingsEditor.joinType.relationshipTable.removeRelationshipTooltipLabel": "移除关系", "xpack.idxMgmt.mappingsEditor.largestShingleSizeFieldLabel": "最大瓦形大小", - "xpack.idxMgmt.mappingsEditor.leafLengthLimitFieldDescription": "如果叶值超过一定长度,则阻止叶值索引。这用于防止超出 Lucene 的字词字符长度限制,即 8,191 个 UTF-8 字符。", - "xpack.idxMgmt.mappingsEditor.leafLengthLimitFieldTitle": "设置长度限制", - "xpack.idxMgmt.mappingsEditor.lengthLimitFieldDescription": "将不索引超过此值的字符串。这用于防止超出 Lucene 的字词字符长度限制,即 8,191 个 UTF-8 字符。", - "xpack.idxMgmt.mappingsEditor.lengthLimitFieldTitle": "设置长度限制", "xpack.idxMgmt.mappingsEditor.loadFromJsonButtonLabel": "加载 JSON", "xpack.idxMgmt.mappingsEditor.loadJsonModal.acceptWarningLabel": "继续加载", "xpack.idxMgmt.mappingsEditor.loadJsonModal.cancelButtonLabel": "取消", From dd1822047c86239769f17d89799ace47c58e6ee6 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 14 Sep 2020 16:31:23 +0200 Subject: [PATCH 06/91] [ML] Transforms: API schemas and integration tests (#75164) - Adds schema definitions to transform API endpoints and adds API integration tests. - The type definitions based on the schema definitions can be used on the client side too. - Adds apidoc documentation. --- x-pack/plugins/ml/common/index.ts | 7 + x-pack/plugins/ml/common/types/es_client.ts | 25 ++ .../application/components/data_grid/index.ts | 1 - .../application/components/data_grid/types.ts | 11 - .../common/get_index_data.ts | 3 +- .../hooks/use_index_data.ts | 2 +- .../common/api_schemas/audit_messages.ts | 9 + .../transform/common/api_schemas/common.ts | 48 +++ .../common/api_schemas/delete_transforms.ts | 37 ++ .../common/api_schemas/field_histograms.ts | 19 + .../common/api_schemas/start_transforms.ts | 13 + .../common/api_schemas/stop_transforms.ts | 19 + .../common/api_schemas/transforms.ts | 127 ++++++ .../common/api_schemas/transforms_stats.ts | 21 + .../common/api_schemas/type_guards.ts | 114 +++++ .../common/api_schemas/update_transforms.ts | 24 ++ x-pack/plugins/transform/common/constants.ts | 21 + x-pack/plugins/transform/common/index.ts | 58 --- .../transform/common/shared_imports.ts | 7 + .../transform/common/types/aggregations.ts | 7 + .../types/es_index.ts} | 0 .../plugins/transform/common/types/fields.ts | 7 + .../transform/common/types/pivot_aggs.ts | 31 ++ .../transform/common/types/pivot_group_by.ts | 33 ++ .../transform/common/types/privileges.ts | 14 + .../transform/common/types/transform.ts | 17 + .../transform/common/types/transform_stats.ts | 62 +++ .../public/__mocks__/shared_imports.ts | 1 - .../public/app/common/aggregations.ts | 2 +- .../public/app/common/data_grid.test.ts | 12 +- .../transform/public/app/common/data_grid.ts | 5 +- .../transform/public/app/common/fields.ts | 2 +- .../transform/public/app/common/index.ts | 30 +- .../transform/public/app/common/pivot_aggs.ts | 33 +- .../public/app/common/pivot_group_by.ts | 31 +- .../public/app/common/pivot_preview.ts | 29 -- .../public/app/common/request.test.ts | 20 +- .../transform/public/app/common/request.ts | 99 +++-- .../transform/public/app/common/transform.ts | 43 +- .../public/app/common/transform_list.ts | 5 +- .../public/app/common/transform_stats.ts | 56 +-- .../public/app/hooks/__mocks__/use_api.ts | 185 +++++++-- .../transform/public/app/hooks/use_api.ts | 222 +++++++--- .../public/app/hooks/use_delete_transform.tsx | 284 ++++++------- .../public/app/hooks/use_get_transforms.ts | 122 +++--- .../public/app/hooks/use_index_data.ts | 56 +-- .../public/app/hooks/use_pivot_data.ts | 68 +-- ...t_transform.ts => use_start_transform.tsx} | 38 +- ...op_transform.ts => use_stop_transform.tsx} | 38 +- .../components/authorization_provider.tsx | 2 +- .../lib/authorization/components/common.ts | 2 +- .../components/with_privileges.tsx | 2 +- .../clone_transform_section.tsx | 44 +- .../aggregation_list/agg_label_form.test.tsx | 5 +- .../aggregation_list/agg_label_form.tsx | 3 +- .../aggregation_list/list_form.test.tsx | 4 +- .../components/aggregation_list/list_form.tsx | 3 +- .../aggregation_list/list_summary.test.tsx | 4 +- .../aggregation_list/list_summary.tsx | 4 +- .../aggregation_list/popover_form.test.tsx | 5 +- .../aggregation_list/popover_form.tsx | 9 +- .../group_by_list/group_by_label_form.tsx | 3 +- .../components/group_by_list/list_form.tsx | 3 +- .../group_by_list/popover_form.test.tsx | 4 +- .../components/group_by_list/popover_form.tsx | 2 +- .../step_create/step_create_form.tsx | 135 +++--- .../apply_transform_config_to_define_state.ts | 8 +- .../components/filter_term_form.tsx | 21 +- .../step_define/common/get_agg_form_config.ts | 8 +- .../get_agg_name_conflict_toast_messages.ts | 5 +- .../common/get_default_aggregation_config.ts | 8 +- .../common/get_default_group_by_config.ts | 8 +- .../components/step_define/common/types.ts | 4 +- .../hooks/use_advanced_pivot_editor.ts | 4 +- .../hooks/use_advanced_source_editor.ts | 4 +- .../step_define/hooks/use_pivot_config.ts | 2 +- .../step_define/hooks/use_step_define_form.ts | 6 +- .../step_define/step_define_form.test.tsx | 3 +- .../step_define/step_define_form.tsx | 9 +- .../step_define/step_define_summary.test.tsx | 3 +- .../step_define/step_define_summary.tsx | 4 +- .../step_details/step_details_form.tsx | 78 ++-- .../components/wizard/wizard.tsx | 6 +- .../action_delete/delete_action_name.tsx | 6 +- .../action_delete/use_delete_action.tsx | 13 +- .../action_edit/use_edit_action.tsx | 4 +- .../action_start/start_action_name.tsx | 2 +- .../action_start/use_start_action.tsx | 4 +- .../action_stop/stop_action_name.tsx | 2 +- .../action_stop/use_stop_action.tsx | 9 +- .../edit_transform_flyout.tsx | 35 +- .../use_edit_transform_flyout.test.ts | 6 +- .../use_edit_transform_flyout.ts | 53 +-- .../components/transform_list/common.test.ts | 2 +- .../expanded_row_messages_pane.tsx | 42 +- .../expanded_row_preview_pane.tsx | 3 +- .../transform_list/transform_list.tsx | 8 +- .../transform_list/transform_search_bar.tsx | 4 +- .../transform_list/transforms_stats_bar.tsx | 4 +- .../components/transform_list/use_columns.tsx | 27 +- .../transform/public/shared_imports.ts | 1 - x-pack/plugins/transform/server/README.md | 19 + .../server/routes/api/error_utils.ts | 13 +- .../server/routes/api/field_histograms.ts | 45 +- .../transform/server/routes/api/privileges.ts | 2 +- .../transform/server/routes/api/schema.ts | 49 --- .../transform/server/routes/api/transforms.ts | 391 ++++++++++++------ .../routes/api/transforms_audit_messages.ts | 26 +- .../transform/server/routes/apidoc.json | 21 + .../transform/server/services/license.ts | 4 +- x-pack/plugins/transform/tsconfig.json | 3 + x-pack/run_functional_tests.sh | 3 - .../api_integration/apis/transform/common.ts | 30 ++ .../apis/transform/delete_transforms.ts | 133 +++--- .../api_integration/apis/transform/index.ts | 6 + .../apis/transform/start_transforms.ts | 164 ++++++++ .../apis/transform/stop_transforms.ts | 197 +++++++++ .../apis/transform/transforms.ts | 165 ++++++++ .../apis/transform/transforms_preview.ts | 72 ++++ .../apis/transform/transforms_stats.ts | 101 +++++ .../apis/transform/transforms_update.ts | 150 +++++++ .../test/functional/apps/transform/cloning.ts | 6 +- .../apps/transform/creation_index_pattern.ts | 6 +- .../apps/transform/creation_saved_search.ts | 4 +- .../test/functional/apps/transform/editing.ts | 10 +- .../test/functional/services/transform/api.ts | 80 +++- .../services/transform/transform_table.ts | 2 +- 127 files changed, 3098 insertions(+), 1362 deletions(-) create mode 100644 x-pack/plugins/ml/common/index.ts create mode 100644 x-pack/plugins/ml/common/types/es_client.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/audit_messages.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/common.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/delete_transforms.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/field_histograms.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/start_transforms.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/stop_transforms.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/transforms.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/transforms_stats.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/type_guards.ts create mode 100644 x-pack/plugins/transform/common/api_schemas/update_transforms.ts delete mode 100644 x-pack/plugins/transform/common/index.ts create mode 100644 x-pack/plugins/transform/common/shared_imports.ts create mode 100644 x-pack/plugins/transform/common/types/aggregations.ts rename x-pack/plugins/transform/{public/app/hooks/use_api_types.ts => common/types/es_index.ts} (100%) create mode 100644 x-pack/plugins/transform/common/types/fields.ts create mode 100644 x-pack/plugins/transform/common/types/pivot_aggs.ts create mode 100644 x-pack/plugins/transform/common/types/pivot_group_by.ts create mode 100644 x-pack/plugins/transform/common/types/privileges.ts create mode 100644 x-pack/plugins/transform/common/types/transform.ts create mode 100644 x-pack/plugins/transform/common/types/transform_stats.ts delete mode 100644 x-pack/plugins/transform/public/app/common/pivot_preview.ts rename x-pack/plugins/transform/public/app/hooks/{use_start_transform.ts => use_start_transform.tsx} (52%) rename x-pack/plugins/transform/public/app/hooks/{use_stop_transform.ts => use_stop_transform.tsx} (53%) create mode 100644 x-pack/plugins/transform/server/README.md delete mode 100644 x-pack/plugins/transform/server/routes/api/schema.ts create mode 100644 x-pack/plugins/transform/server/routes/apidoc.json create mode 100644 x-pack/plugins/transform/tsconfig.json delete mode 100755 x-pack/run_functional_tests.sh create mode 100644 x-pack/test/api_integration/apis/transform/common.ts create mode 100644 x-pack/test/api_integration/apis/transform/start_transforms.ts create mode 100644 x-pack/test/api_integration/apis/transform/stop_transforms.ts create mode 100644 x-pack/test/api_integration/apis/transform/transforms.ts create mode 100644 x-pack/test/api_integration/apis/transform/transforms_preview.ts create mode 100644 x-pack/test/api_integration/apis/transform/transforms_stats.ts create mode 100644 x-pack/test/api_integration/apis/transform/transforms_update.ts diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts new file mode 100644 index 0000000000000..791a7de48f36f --- /dev/null +++ b/x-pack/plugins/ml/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SearchResponse7 } from './types/es_client'; diff --git a/x-pack/plugins/ml/common/types/es_client.ts b/x-pack/plugins/ml/common/types/es_client.ts new file mode 100644 index 0000000000000..d9ca9a3b584ab --- /dev/null +++ b/x-pack/plugins/ml/common/types/es_client.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse, ShardsResponse } from 'elasticsearch'; + +// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. +interface SearchResponse7Hits { + hits: SearchResponse['hits']['hits']; + max_score: number; + total: { + value: number; + relation: string; + }; +} +export interface SearchResponse7 { + took: number; + timed_out: boolean; + _scroll_id?: string; + _shards: ShardsResponse; + hits: SearchResponse7Hits; + aggregations?: any; +} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 4bbd3595e5a7e..633d70687dd27 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -19,7 +19,6 @@ export { DataGridItem, EsSorting, RenderCellValue, - SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, } from './types'; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index f9ee8c37fabf7..22fff0f6e0b93 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -5,7 +5,6 @@ */ import { Dispatch, SetStateAction } from 'react'; -import { SearchResponse } from 'elasticsearch'; import { EuiDataGridPaginationProps, EuiDataGridSorting, EuiDataGridColumn } from '@elastic/eui'; @@ -43,16 +42,6 @@ export type EsSorting = Dictionary<{ order: 'asc' | 'desc'; }>; -// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. -export interface SearchResponse7 extends SearchResponse { - hits: SearchResponse['hits'] & { - total: { - value: number; - relation: string; - }; - }; -} - export interface UseIndexDataReturnType extends Pick< UseDataGridReturnType, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts index 53c0f02fd9a80..361a79d42214d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { SearchResponse7 } from '../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../common/util/errors'; -import { EsSorting, SearchResponse7, UseDataGridReturnType } from '../../components/data_grid'; +import { EsSorting, UseDataGridReturnType } from '../../components/data_grid'; import { ml } from '../../services/ml_api_service'; import { isKeywordAndTextType } from '../common/fields'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ea958c8c4a3a3..74d45b86c8c4d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -22,9 +22,9 @@ import { useDataGrid, useRenderCellValue, EsSorting, - SearchResponse7, UseIndexDataReturnType, } from '../../../../components/data_grid'; +import type { SearchResponse7 } from '../../../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../../../common/util/errors'; import { INDEX_STATUS } from '../../../common/analytics'; import { ml } from '../../../../services/ml_api_service'; diff --git a/x-pack/plugins/transform/common/api_schemas/audit_messages.ts b/x-pack/plugins/transform/common/api_schemas/audit_messages.ts new file mode 100644 index 0000000000000..76e63af262674 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/audit_messages.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TransformMessage } from '../types/messages'; + +export type GetTransformsAuditMessagesResponseSchema = TransformMessage[]; diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts new file mode 100644 index 0000000000000..80b14ce6adee8 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { TRANSFORM_STATE } from '../constants'; + +export const transformIdsSchema = schema.arrayOf( + schema.object({ + id: schema.string(), + }) +); + +export type TransformIdsSchema = TypeOf; + +export const transformStateSchema = schema.oneOf([ + schema.literal(TRANSFORM_STATE.ABORTING), + schema.literal(TRANSFORM_STATE.FAILED), + schema.literal(TRANSFORM_STATE.INDEXING), + schema.literal(TRANSFORM_STATE.STARTED), + schema.literal(TRANSFORM_STATE.STOPPED), + schema.literal(TRANSFORM_STATE.STOPPING), +]); + +export const indexPatternTitleSchema = schema.object({ + /** Title of the index pattern for which to return stats. */ + indexPatternTitle: schema.string(), +}); + +export type IndexPatternTitleSchema = TypeOf; + +export const transformIdParamSchema = schema.object({ + transformId: schema.string(), +}); + +export type TransformIdParamSchema = TypeOf; + +export interface ResponseStatus { + success: boolean; + error?: any; +} + +export interface CommonResponseStatusSchema { + [key: string]: ResponseStatus; +} diff --git a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts new file mode 100644 index 0000000000000..c4d1a1f5f7587 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { transformStateSchema, ResponseStatus } from './common'; + +export const deleteTransformsRequestSchema = schema.object({ + /** + * Delete Transform & Destination Index + */ + transformsInfo: schema.arrayOf( + schema.object({ + id: schema.string(), + state: transformStateSchema, + }) + ), + deleteDestIndex: schema.maybe(schema.boolean()), + deleteDestIndexPattern: schema.maybe(schema.boolean()), + forceDelete: schema.maybe(schema.boolean()), +}); + +export type DeleteTransformsRequestSchema = TypeOf; + +export interface DeleteTransformStatus { + transformDeleted: ResponseStatus; + destIndexDeleted?: ResponseStatus; + destIndexPatternDeleted?: ResponseStatus; + destinationIndex?: string | undefined; +} + +export interface DeleteTransformsResponseSchema { + [key: string]: DeleteTransformStatus; +} diff --git a/x-pack/plugins/transform/common/api_schemas/field_histograms.ts b/x-pack/plugins/transform/common/api_schemas/field_histograms.ts new file mode 100644 index 0000000000000..3bdbb5f1ff702 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/field_histograms.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const fieldHistogramsRequestSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + +export type FieldHistogramsRequestSchema = TypeOf; +export type FieldHistogramsResponseSchema = any[]; diff --git a/x-pack/plugins/transform/common/api_schemas/start_transforms.ts b/x-pack/plugins/transform/common/api_schemas/start_transforms.ts new file mode 100644 index 0000000000000..b9611636e61a8 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/start_transforms.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; + +import { transformIdsSchema, CommonResponseStatusSchema } from './common'; + +export const startTransformsRequestSchema = transformIdsSchema; +export type StartTransformsRequestSchema = TypeOf; +export type StartTransformsResponseSchema = CommonResponseStatusSchema; diff --git a/x-pack/plugins/transform/common/api_schemas/stop_transforms.ts b/x-pack/plugins/transform/common/api_schemas/stop_transforms.ts new file mode 100644 index 0000000000000..56956de20b49e --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/stop_transforms.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { transformStateSchema, CommonResponseStatusSchema } from './common'; + +export const stopTransformsRequestSchema = schema.arrayOf( + schema.object({ + id: schema.string(), + state: transformStateSchema, + }) +); + +export type StopTransformsRequestSchema = TypeOf; +export type StopTransformsResponseSchema = CommonResponseStatusSchema; diff --git a/x-pack/plugins/transform/common/api_schemas/transforms.ts b/x-pack/plugins/transform/common/api_schemas/transforms.ts new file mode 100644 index 0000000000000..155807a5c445f --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/transforms.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import type { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; + +import type { Dictionary } from '../types/common'; +import type { PivotAggDict } from '../types/pivot_aggs'; +import type { PivotGroupByDict } from '../types/pivot_group_by'; +import type { TransformId, TransformPivotConfig } from '../types/transform'; + +import { transformStateSchema } from './common'; + +// GET transforms +export const getTransformsRequestSchema = schema.arrayOf( + schema.object({ + id: schema.string(), + state: transformStateSchema, + }) +); + +export type GetTransformsRequestSchema = TypeOf; + +export interface GetTransformsResponseSchema { + count: number; + transforms: TransformPivotConfig[]; +} + +// schemas shared by parts of the preview, create and update endpoint +export const destSchema = schema.object({ + index: schema.string(), + pipeline: schema.maybe(schema.string()), +}); +export const pivotSchema = schema.object({ + group_by: schema.any(), + aggregations: schema.any(), +}); +export const settingsSchema = schema.object({ + max_page_search_size: schema.maybe(schema.number()), + // The default value is null, which disables throttling. + docs_per_second: schema.maybe(schema.nullable(schema.number())), +}); +export const sourceSchema = schema.object({ + index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + query: schema.maybe(schema.recordOf(schema.string(), schema.any())), +}); +export const syncSchema = schema.object({ + time: schema.object({ + delay: schema.maybe(schema.string()), + field: schema.string(), + }), +}); + +// PUT transforms/{transformId} +export const putTransformsRequestSchema = schema.object({ + description: schema.maybe(schema.string()), + dest: destSchema, + frequency: schema.maybe(schema.string()), + pivot: pivotSchema, + settings: schema.maybe(settingsSchema), + source: sourceSchema, + sync: schema.maybe(syncSchema), +}); + +export interface PutTransformsRequestSchema extends TypeOf { + pivot: { + group_by: PivotGroupByDict; + aggregations: PivotAggDict; + }; +} + +interface TransformCreated { + transform: TransformId; +} +interface TransformCreatedError { + id: TransformId; + error: any; +} +export interface PutTransformsResponseSchema { + transformsCreated: TransformCreated[]; + errors: TransformCreatedError[]; +} + +// POST transforms/_preview +export const postTransformsPreviewRequestSchema = schema.object({ + pivot: pivotSchema, + source: sourceSchema, +}); + +export interface PostTransformsPreviewRequestSchema + extends TypeOf { + pivot: { + group_by: PivotGroupByDict; + aggregations: PivotAggDict; + }; +} + +interface EsMappingType { + type: ES_FIELD_TYPES; +} + +export type PreviewItem = Dictionary; +export type PreviewData = PreviewItem[]; +export type PreviewMappingsProperties = Dictionary; + +export interface PostTransformsPreviewResponseSchema { + generated_dest_index: { + mappings: { + _meta: { + _transform: { + transform: string; + version: { create: string }; + creation_date_in_millis: number; + }; + created_by: string; + }; + properties: PreviewMappingsProperties; + }; + settings: { index: { number_of_shards: string; auto_expand_replicas: string } }; + aliases: Record; + }; + preview: PreviewData; +} diff --git a/x-pack/plugins/transform/common/api_schemas/transforms_stats.ts b/x-pack/plugins/transform/common/api_schemas/transforms_stats.ts new file mode 100644 index 0000000000000..30661a8a407da --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/transforms_stats.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; + +import { TransformStats } from '../types/transform_stats'; + +import { getTransformsRequestSchema } from './transforms'; + +export const getTransformsStatsRequestSchema = getTransformsRequestSchema; + +export type GetTransformsRequestSchema = TypeOf; + +export interface GetTransformsStatsResponseSchema { + node_failures?: object; + count: number; + transforms: TransformStats[]; +} diff --git a/x-pack/plugins/transform/common/api_schemas/type_guards.ts b/x-pack/plugins/transform/common/api_schemas/type_guards.ts new file mode 100644 index 0000000000000..f9753a412527e --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/type_guards.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { SearchResponse7 } from '../../../ml/common'; + +import type { EsIndex } from '../types/es_index'; + +// To be able to use the type guards on the client side, we need to make sure we don't import +// the code of '@kbn/config-schema' but just its types, otherwise the client side code will +// fail to build. +import type { FieldHistogramsResponseSchema } from './field_histograms'; +import type { GetTransformsAuditMessagesResponseSchema } from './audit_messages'; +import type { DeleteTransformsResponseSchema } from './delete_transforms'; +import type { StartTransformsResponseSchema } from './start_transforms'; +import type { StopTransformsResponseSchema } from './stop_transforms'; +import type { + GetTransformsResponseSchema, + PostTransformsPreviewResponseSchema, + PutTransformsResponseSchema, +} from './transforms'; +import type { GetTransformsStatsResponseSchema } from './transforms_stats'; +import type { PostTransformsUpdateResponseSchema } from './update_transforms'; + +const isBasicObject = (arg: any) => { + return typeof arg === 'object' && arg !== null; +}; + +const isGenericResponseSchema = (arg: any): arg is T => { + return ( + isBasicObject(arg) && + {}.hasOwnProperty.call(arg, 'count') && + {}.hasOwnProperty.call(arg, 'transforms') && + Array.isArray(arg.transforms) + ); +}; + +export const isGetTransformsResponseSchema = (arg: any): arg is GetTransformsResponseSchema => { + return isGenericResponseSchema(arg); +}; + +export const isGetTransformsStatsResponseSchema = ( + arg: any +): arg is GetTransformsStatsResponseSchema => { + return isGenericResponseSchema(arg); +}; + +export const isDeleteTransformsResponseSchema = ( + arg: any +): arg is DeleteTransformsResponseSchema => { + return ( + isBasicObject(arg) && + Object.values(arg).every((d) => ({}.hasOwnProperty.call(d, 'transformDeleted'))) + ); +}; + +export const isEsIndices = (arg: any): arg is EsIndex[] => { + return Array.isArray(arg); +}; + +export const isEsSearchResponse = (arg: any): arg is SearchResponse7 => { + return isBasicObject(arg) && {}.hasOwnProperty.call(arg, 'hits'); +}; + +export const isFieldHistogramsResponseSchema = (arg: any): arg is FieldHistogramsResponseSchema => { + return Array.isArray(arg); +}; + +export const isGetTransformsAuditMessagesResponseSchema = ( + arg: any +): arg is GetTransformsAuditMessagesResponseSchema => { + return Array.isArray(arg); +}; + +export const isPostTransformsPreviewResponseSchema = ( + arg: any +): arg is PostTransformsPreviewResponseSchema => { + return ( + isBasicObject(arg) && + {}.hasOwnProperty.call(arg, 'generated_dest_index') && + {}.hasOwnProperty.call(arg, 'preview') && + typeof arg.generated_dest_index !== undefined && + Array.isArray(arg.preview) + ); +}; + +export const isPostTransformsUpdateResponseSchema = ( + arg: any +): arg is PostTransformsUpdateResponseSchema => { + return isBasicObject(arg) && {}.hasOwnProperty.call(arg, 'id') && typeof arg.id === 'string'; +}; + +export const isPutTransformsResponseSchema = (arg: any): arg is PutTransformsResponseSchema => { + return ( + isBasicObject(arg) && + {}.hasOwnProperty.call(arg, 'transformsCreated') && + {}.hasOwnProperty.call(arg, 'errors') && + Array.isArray(arg.transformsCreated) && + Array.isArray(arg.errors) + ); +}; + +const isGenericSuccessResponseSchema = (arg: any) => + isBasicObject(arg) && Object.values(arg).every((d) => ({}.hasOwnProperty.call(d, 'success'))); + +export const isStartTransformsResponseSchema = (arg: any): arg is StartTransformsResponseSchema => { + return isGenericSuccessResponseSchema(arg); +}; + +export const isStopTransformsResponseSchema = (arg: any): arg is StopTransformsResponseSchema => { + return isGenericSuccessResponseSchema(arg); +}; diff --git a/x-pack/plugins/transform/common/api_schemas/update_transforms.ts b/x-pack/plugins/transform/common/api_schemas/update_transforms.ts new file mode 100644 index 0000000000000..e303d94ef0536 --- /dev/null +++ b/x-pack/plugins/transform/common/api_schemas/update_transforms.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { TransformPivotConfig } from '../types/transform'; + +import { destSchema, settingsSchema, sourceSchema, syncSchema } from './transforms'; + +// POST _transform/{transform_id}/_update +export const postTransformsUpdateRequestSchema = schema.object({ + description: schema.maybe(schema.string()), + dest: schema.maybe(destSchema), + frequency: schema.maybe(schema.string()), + settings: schema.maybe(settingsSchema), + source: schema.maybe(sourceSchema), + sync: schema.maybe(syncSchema), +}); + +export type PostTransformsUpdateRequestSchema = TypeOf; +export type PostTransformsUpdateResponseSchema = TransformPivotConfig; diff --git a/x-pack/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts index b01a82dffa04a..5efb6f31c1e3f 100644 --- a/x-pack/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -75,3 +75,24 @@ export const APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES = [ ]; export const APP_INDEX_PRIVILEGES = ['monitor']; + +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 +export const TRANSFORM_STATE = { + ABORTING: 'aborting', + FAILED: 'failed', + INDEXING: 'indexing', + STARTED: 'started', + STOPPED: 'stopped', + STOPPING: 'stopping', +} as const; + +const transformStates = Object.values(TRANSFORM_STATE); +export type TransformState = typeof transformStates[number]; + +export const TRANSFORM_MODE = { + BATCH: 'batch', + CONTINUOUS: 'continuous', +} as const; + +const transformModes = Object.values(TRANSFORM_MODE); +export type TransformMode = typeof transformModes[number]; diff --git a/x-pack/plugins/transform/common/index.ts b/x-pack/plugins/transform/common/index.ts deleted file mode 100644 index 08bb4022c7016..0000000000000 --- a/x-pack/plugins/transform/common/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export interface MissingPrivileges { - [key: string]: string[] | undefined; -} - -export interface Privileges { - hasAllPrivileges: boolean; - missingPrivileges: MissingPrivileges; -} - -export type TransformId = string; - -// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 -export enum TRANSFORM_STATE { - ABORTING = 'aborting', - FAILED = 'failed', - INDEXING = 'indexing', - STARTED = 'started', - STOPPED = 'stopped', - STOPPING = 'stopping', -} - -export interface TransformEndpointRequest { - id: TransformId; - state?: TRANSFORM_STATE; -} - -export interface ResultData { - success: boolean; - error?: any; -} - -export interface TransformEndpointResult { - [key: string]: ResultData; -} - -export interface DeleteTransformEndpointRequest { - transformsInfo: TransformEndpointRequest[]; - deleteDestIndex?: boolean; - deleteDestIndexPattern?: boolean; - forceDelete?: boolean; -} - -export interface DeleteTransformStatus { - transformDeleted: ResultData; - destIndexDeleted?: ResultData; - destIndexPatternDeleted?: ResultData; - destinationIndex?: string | undefined; -} - -export interface DeleteTransformEndpointResult { - [key: string]: DeleteTransformStatus; -} diff --git a/x-pack/plugins/transform/common/shared_imports.ts b/x-pack/plugins/transform/common/shared_imports.ts new file mode 100644 index 0000000000000..8681204755c36 --- /dev/null +++ b/x-pack/plugins/transform/common/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type { SearchResponse7 } from '../../ml/common'; diff --git a/x-pack/plugins/transform/common/types/aggregations.ts b/x-pack/plugins/transform/common/types/aggregations.ts new file mode 100644 index 0000000000000..77b7e55e3ba94 --- /dev/null +++ b/x-pack/plugins/transform/common/types/aggregations.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type AggName = string; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api_types.ts b/x-pack/plugins/transform/common/types/es_index.ts similarity index 100% rename from x-pack/plugins/transform/public/app/hooks/use_api_types.ts rename to x-pack/plugins/transform/common/types/es_index.ts diff --git a/x-pack/plugins/transform/common/types/fields.ts b/x-pack/plugins/transform/common/types/fields.ts new file mode 100644 index 0000000000000..2c274f3bd9b48 --- /dev/null +++ b/x-pack/plugins/transform/common/types/fields.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type EsFieldName = string; diff --git a/x-pack/plugins/transform/common/types/pivot_aggs.ts b/x-pack/plugins/transform/common/types/pivot_aggs.ts new file mode 100644 index 0000000000000..d50609da6a5dc --- /dev/null +++ b/x-pack/plugins/transform/common/types/pivot_aggs.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AggName } from './aggregations'; +import { EsFieldName } from './fields'; + +export const PIVOT_SUPPORTED_AGGS = { + AVG: 'avg', + CARDINALITY: 'cardinality', + MAX: 'max', + MIN: 'min', + PERCENTILES: 'percentiles', + SUM: 'sum', + VALUE_COUNT: 'value_count', + FILTER: 'filter', +} as const; + +export type PivotSupportedAggs = typeof PIVOT_SUPPORTED_AGGS[keyof typeof PIVOT_SUPPORTED_AGGS]; + +export type PivotAgg = { + [key in PivotSupportedAggs]?: { + field: EsFieldName; + }; +}; + +export type PivotAggDict = { + [key in AggName]: PivotAgg; +}; diff --git a/x-pack/plugins/transform/common/types/pivot_group_by.ts b/x-pack/plugins/transform/common/types/pivot_group_by.ts new file mode 100644 index 0000000000000..bfaf17a32b580 --- /dev/null +++ b/x-pack/plugins/transform/common/types/pivot_group_by.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dictionary } from './common'; +import { EsFieldName } from './fields'; + +export type GenericAgg = object; + +export interface TermsAgg { + terms: { + field: EsFieldName; + }; +} + +export interface HistogramAgg { + histogram: { + field: EsFieldName; + interval: string; + }; +} + +export interface DateHistogramAgg { + date_histogram: { + field: EsFieldName; + calendar_interval: string; + }; +} + +export type PivotGroupBy = GenericAgg | TermsAgg | HistogramAgg | DateHistogramAgg; +export type PivotGroupByDict = Dictionary; diff --git a/x-pack/plugins/transform/common/types/privileges.ts b/x-pack/plugins/transform/common/types/privileges.ts new file mode 100644 index 0000000000000..bf710b8225599 --- /dev/null +++ b/x-pack/plugins/transform/common/types/privileges.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface MissingPrivileges { + [key: string]: string[] | undefined; +} + +export interface Privileges { + hasAllPrivileges: boolean; + missingPrivileges: MissingPrivileges; +} diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts new file mode 100644 index 0000000000000..6b31705442706 --- /dev/null +++ b/x-pack/plugins/transform/common/types/transform.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PutTransformsRequestSchema } from '../api_schemas/transforms'; + +export type IndexName = string; +export type IndexPattern = string; +export type TransformId = string; + +export interface TransformPivotConfig extends PutTransformsRequestSchema { + id: TransformId; + create_time?: number; + version?: string; +} diff --git a/x-pack/plugins/transform/common/types/transform_stats.ts b/x-pack/plugins/transform/common/types/transform_stats.ts new file mode 100644 index 0000000000000..5bd2fd955845c --- /dev/null +++ b/x-pack/plugins/transform/common/types/transform_stats.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TransformState, TRANSFORM_STATE } from '../constants'; +import { TransformId } from './transform'; + +export interface TransformStats { + id: TransformId; + checkpointing: { + last: { + checkpoint: number; + timestamp_millis?: number; + }; + next?: { + checkpoint: number; + checkpoint_progress?: { + total_docs: number; + docs_remaining: number; + percent_complete: number; + }; + }; + operations_behind: number; + }; + node?: { + id: string; + name: string; + ephemeral_id: string; + transport_address: string; + attributes: Record; + }; + stats: { + documents_indexed: number; + documents_processed: number; + index_failures: number; + index_time_in_ms: number; + index_total: number; + pages_processed: number; + search_failures: number; + search_time_in_ms: number; + search_total: number; + trigger_count: number; + processing_time_in_ms: number; + processing_total: number; + exponential_avg_checkpoint_duration_ms: number; + exponential_avg_documents_indexed: number; + exponential_avg_documents_processed: number; + }; + reason?: string; + state: TransformState; +} + +export function isTransformStats(arg: any): arg is TransformStats { + return ( + typeof arg === 'object' && + arg !== null && + {}.hasOwnProperty.call(arg, 'state') && + Object.values(TRANSFORM_STATE).includes(arg.state) + ); +} diff --git a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts index f7441fd93f38a..470c42d5de7fa 100644 --- a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts @@ -23,7 +23,6 @@ export { DataGrid, EsSorting, RenderCellValue, - SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, INDEX_STATUS, diff --git a/x-pack/plugins/transform/public/app/common/aggregations.ts b/x-pack/plugins/transform/public/app/common/aggregations.ts index 397a58006f1d1..507579d374353 100644 --- a/x-pack/plugins/transform/public/app/common/aggregations.ts +++ b/x-pack/plugins/transform/public/app/common/aggregations.ts @@ -6,7 +6,7 @@ import { composeValidators, patternValidator } from '../../../../ml/public'; -export type AggName = string; +import { AggName } from '../../../common/types/aggregations'; export function isAggName(arg: any): arg is AggName { // allow all characters except `[]>` and must not start or end with a space. diff --git a/x-pack/plugins/transform/public/app/common/data_grid.test.ts b/x-pack/plugins/transform/public/app/common/data_grid.test.ts index 0e5ecb5d3b214..6d96f614b28a4 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.test.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.test.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PIVOT_SUPPORTED_AGGS } from '../../../common/types/pivot_aggs'; + import { - getPreviewRequestBody, + getPreviewTransformRequestBody, PivotAggsConfig, PivotGroupByConfig, - PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, SimpleQuery, } from '../common'; @@ -35,7 +36,12 @@ describe('Transform: Data Grid', () => { aggName: 'the-agg-agg-name', dropDownName: 'the-agg-drop-down-name', }; - const request = getPreviewRequestBody('the-index-pattern-title', query, [groupBy], [agg]); + const request = getPreviewTransformRequestBody( + 'the-index-pattern-title', + query, + [groupBy], + [agg] + ); const pivotPreviewDevConsoleStatement = getPivotPreviewDevConsoleStatement(request); expect(pivotPreviewDevConsoleStatement).toBe(`POST _transform/_preview diff --git a/x-pack/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts index cf9ba5d6f5853..08f834431fa8b 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { PostTransformsPreviewRequestSchema } from '../../../common/api_schemas/transforms'; + import { PivotQuery } from './request'; -import { PreviewRequestBody } from './transform'; export const INIT_MAX_COLUMNS = 20; -export const getPivotPreviewDevConsoleStatement = (request: PreviewRequestBody) => { +export const getPivotPreviewDevConsoleStatement = (request: PostTransformsPreviewRequestSchema) => { return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`; }; diff --git a/x-pack/plugins/transform/public/app/common/fields.ts b/x-pack/plugins/transform/public/app/common/fields.ts index b22aae255b9fa..778750e1f97e4 100644 --- a/x-pack/plugins/transform/public/app/common/fields.ts +++ b/x-pack/plugins/transform/public/app/common/fields.ts @@ -5,10 +5,10 @@ */ import { Dictionary } from '../../../common/types/common'; +import { EsFieldName } from '../../../common/types/fields'; export type EsId = string; export type EsDocSource = Dictionary; -export type EsFieldName = string; export interface EsDoc extends Dictionary { _id: EsId; diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index 45ddc440057b2..0fc947eaf33b0 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AggName, isAggName } from './aggregations'; +export { isAggName } from './aggregations'; export { getIndexDevConsoleStatement, getPivotPreviewDevConsoleStatement, @@ -17,44 +17,28 @@ export { toggleSelectedField, EsDoc, EsDocSource, - EsFieldName, } from './fields'; export { DropDownLabel, DropDownOption, Label } from './dropdown'; export { isTransformIdValid, refreshTransformList$, useRefreshTransformList, - CreateRequestBody, - PreviewRequestBody, - TransformPivotConfig, - IndexName, - IndexPattern, REFRESH_TRANSFORM_LIST_STATE, } from './transform'; export { TRANSFORM_LIST_COLUMN, TransformListAction, TransformListRow } from './transform_list'; -export { - getTransformProgress, - isCompletedBatchTransform, - isTransformStats, - TransformStats, - TRANSFORM_MODE, -} from './transform_stats'; +export { getTransformProgress, isCompletedBatchTransform } from './transform_stats'; export { getDiscoverUrl } from './navigation'; -export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_preview'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, isPivotAggsConfigPercentiles, PERCENTILES_AGG_DEFAULT_PERCENTS, - PivotAgg, - PivotAggDict, PivotAggsConfig, PivotAggsConfigDict, PivotAggsConfigBase, PivotAggsConfigWithUiSupport, PivotAggsConfigWithUiSupportDict, pivotAggsFieldSupport, - PIVOT_SUPPORTED_AGGS, } from './pivot_aggs'; export { dateHistogramIntervalFormatRegex, @@ -65,25 +49,19 @@ export { isGroupByHistogram, isGroupByTerms, pivotGroupByFieldSupport, - DateHistogramAgg, - GenericAgg, GroupByConfigWithInterval, GroupByConfigWithUiSupport, - HistogramAgg, - PivotGroupBy, PivotGroupByConfig, - PivotGroupByDict, PivotGroupByConfigDict, PivotGroupByConfigWithUiSupportDict, PivotSupportedGroupByAggs, PivotSupportedGroupByAggsWithInterval, PIVOT_SUPPORTED_GROUP_BY_AGGS, - TermsAgg, } from './pivot_group_by'; export { defaultQuery, - getPreviewRequestBody, - getCreateRequestBody, + getPreviewTransformRequestBody, + getCreateTransformRequestBody, getPivotQuery, isDefaultQuery, isMatchAllQuery, diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index ec52de4b9da92..7a7bb4c65b306 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -5,31 +5,22 @@ */ import { FC } from 'react'; -import { Dictionary } from '../../../common/types/common'; + import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; -import { AggName } from './aggregations'; -import { EsFieldName } from './fields'; +import type { AggName } from '../../../common/types/aggregations'; +import type { Dictionary } from '../../../common/types/common'; +import type { EsFieldName } from '../../../common/types/fields'; +import type { PivotAgg, PivotSupportedAggs } from '../../../common/types/pivot_aggs'; +import { PIVOT_SUPPORTED_AGGS } from '../../../common/types/pivot_aggs'; + import { getAggFormConfig } from '../sections/create_transform/components/step_define/common/get_agg_form_config'; import { PivotAggsConfigFilter } from '../sections/create_transform/components/step_define/common/filter_agg/types'; -export type PivotSupportedAggs = typeof PIVOT_SUPPORTED_AGGS[keyof typeof PIVOT_SUPPORTED_AGGS]; - export function isPivotSupportedAggs(arg: any): arg is PivotSupportedAggs { return Object.values(PIVOT_SUPPORTED_AGGS).includes(arg); } -export const PIVOT_SUPPORTED_AGGS = { - AVG: 'avg', - CARDINALITY: 'cardinality', - MAX: 'max', - MIN: 'min', - PERCENTILES: 'percentiles', - SUM: 'sum', - VALUE_COUNT: 'value_count', - FILTER: 'filter', -} as const; - export const PERCENTILES_AGG_DEFAULT_PERCENTS = [1, 5, 25, 50, 75, 95, 99]; export const pivotAggsFieldSupport = { @@ -69,16 +60,6 @@ export const pivotAggsFieldSupport = { [KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], }; -export type PivotAgg = { - [key in PivotSupportedAggs]?: { - field: EsFieldName; - }; -}; - -export type PivotAggDict = { - [key in AggName]: PivotAgg; -}; - /** * The maximum level of sub-aggregations */ diff --git a/x-pack/plugins/transform/public/app/common/pivot_group_by.ts b/x-pack/plugins/transform/public/app/common/pivot_group_by.ts index 7da52fc018338..2c2bac369c72d 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_group_by.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_group_by.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AggName } from '../../../common/types/aggregations'; import { Dictionary } from '../../../common/types/common'; +import { EsFieldName } from '../../../common/types/fields'; +import { GenericAgg } from '../../../common/types/pivot_group_by'; import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; -import { AggName } from './aggregations'; -import { EsFieldName } from './fields'; - export enum PIVOT_SUPPORTED_GROUP_BY_AGGS { DATE_HISTOGRAM = 'date_histogram', HISTOGRAM = 'histogram', @@ -106,31 +106,6 @@ export function isPivotGroupByConfigWithUiSupport(arg: any): arg is GroupByConfi return isGroupByDateHistogram(arg) || isGroupByHistogram(arg) || isGroupByTerms(arg); } -export type GenericAgg = object; - -export interface TermsAgg { - terms: { - field: EsFieldName; - }; -} - -export interface HistogramAgg { - histogram: { - field: EsFieldName; - interval: string; - }; -} - -export interface DateHistogramAgg { - date_histogram: { - field: EsFieldName; - calendar_interval: string; - }; -} - -export type PivotGroupBy = GenericAgg | TermsAgg | HistogramAgg | DateHistogramAgg; -export type PivotGroupByDict = Dictionary; - export function getEsAggFromGroupByConfig(groupByConfig: GroupByConfigBase): GenericAgg { const { agg, aggName, dropDownName, ...esAgg } = groupByConfig; diff --git a/x-pack/plugins/transform/public/app/common/pivot_preview.ts b/x-pack/plugins/transform/public/app/common/pivot_preview.ts deleted file mode 100644 index 14368a80b0131..0000000000000 --- a/x-pack/plugins/transform/public/app/common/pivot_preview.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; - -import { Dictionary } from '../../../common/types/common'; - -interface EsMappingType { - type: ES_FIELD_TYPES; -} - -export type PreviewItem = Dictionary; -export type PreviewData = PreviewItem[]; -export interface PreviewMappings { - properties: Dictionary; -} - -export interface GetTransformsResponse { - preview: PreviewData; - generated_dest_index: { - mappings: PreviewMappings; - // Not in use yet - aliases: any; - settings: any; - }; -} diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index 63f1f8b10ad44..416927c460842 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PIVOT_SUPPORTED_AGGS } from '../../../common/types/pivot_aggs'; + import { PivotGroupByConfig } from '../common'; import { StepDefineExposedState } from '../sections/create_transform/components/step_define'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; import { PIVOT_SUPPORTED_GROUP_BY_AGGS } from './pivot_group_by'; -import { PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from './pivot_aggs'; +import { PivotAggsConfig } from './pivot_aggs'; import { defaultQuery, - getPreviewRequestBody, - getCreateRequestBody, + getPreviewTransformRequestBody, + getCreateTransformRequestBody, getPivotQuery, isDefaultQuery, isMatchAllQuery, @@ -55,7 +57,7 @@ describe('Transform: Common', () => { }); }); - test('getPreviewRequestBody()', () => { + test('getPreviewTransformRequestBody()', () => { const query = getPivotQuery('the-query'); const groupBy: PivotGroupByConfig[] = [ { @@ -73,7 +75,7 @@ describe('Transform: Common', () => { dropDownName: 'the-agg-drop-down-name', }, ]; - const request = getPreviewRequestBody('the-index-pattern-title', query, groupBy, aggs); + const request = getPreviewTransformRequestBody('the-index-pattern-title', query, groupBy, aggs); expect(request).toEqual({ pivot: { @@ -87,7 +89,7 @@ describe('Transform: Common', () => { }); }); - test('getPreviewRequestBody() with comma-separated index pattern', () => { + test('getPreviewTransformRequestBody() with comma-separated index pattern', () => { const query = getPivotQuery('the-query'); const groupBy: PivotGroupByConfig[] = [ { @@ -105,7 +107,7 @@ describe('Transform: Common', () => { dropDownName: 'the-agg-drop-down-name', }, ]; - const request = getPreviewRequestBody( + const request = getPreviewTransformRequestBody( 'the-index-pattern-title,the-other-title', query, groupBy, @@ -124,7 +126,7 @@ describe('Transform: Common', () => { }); }); - test('getCreateRequestBody()', () => { + test('getCreateTransformRequestBody()', () => { const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, field: 'the-group-by-field', @@ -160,7 +162,7 @@ describe('Transform: Common', () => { valid: true, }; - const request = getCreateRequestBody( + const request = getCreateTransformRequestBody( 'the-index-pattern-title', pivotState, transformDetailsState diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 9a0084c2ebffb..10f3a63477029 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -4,15 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DefaultOperator } from 'elasticsearch'; - +import type { DefaultOperator } from 'elasticsearch'; + +import { HttpFetchError } from '../../../../../../src/core/public'; +import type { IndexPattern } from '../../../../../../src/plugins/data/public'; + +import type { + PostTransformsPreviewRequestSchema, + PutTransformsRequestSchema, +} from '../../../common/api_schemas/transforms'; +import type { + DateHistogramAgg, + HistogramAgg, + TermsAgg, +} from '../../../common/types/pivot_group_by'; import { dictionaryToArray } from '../../../common/types/common'; -import { SavedSearchQuery } from '../hooks/use_search_items'; - -import { StepDefineExposedState } from '../sections/create_transform/components/step_define'; -import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; -import { IndexPattern } from '../../../../../../src/plugins/data/public'; +import type { SavedSearchQuery } from '../hooks/use_search_items'; +import type { StepDefineExposedState } from '../sections/create_transform/components/step_define'; +import type { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; import { getEsAggFromAggConfig, @@ -24,8 +34,6 @@ import { } from '../common'; import { PivotAggsConfig } from './pivot_aggs'; -import { DateHistogramAgg, HistogramAgg, TermsAgg } from './pivot_group_by'; -import { PreviewRequestBody, CreateRequestBody } from './transform'; export interface SimpleQuery { query_string: { @@ -63,17 +71,18 @@ export function isDefaultQuery(query: PivotQuery): boolean { return isSimpleQuery(query) && query.query_string.query === '*'; } -export function getPreviewRequestBody( +export function getPreviewTransformRequestBody( indexPatternTitle: IndexPattern['title'], query: PivotQuery, groupBy: PivotGroupByConfig[], aggs: PivotAggsConfig[] -): PreviewRequestBody { +): PostTransformsPreviewRequestSchema { const index = indexPatternTitle.split(',').map((name: string) => name.trim()); - const request: PreviewRequestBody = { + const request: PostTransformsPreviewRequestSchema = { source: { index, + ...(!isDefaultQuery(query) && !isMatchAllQuery(query) ? { query } : {}), }, pivot: { group_by: {}, @@ -81,10 +90,6 @@ export function getPreviewRequestBody( }, }; - if (!isDefaultQuery(query) && !isMatchAllQuery(query)) { - request.source.query = query; - } - groupBy.forEach((g) => { if (isGroupByTerms(g)) { const termsAgg: TermsAgg = { @@ -125,37 +130,41 @@ export function getPreviewRequestBody( return request; } -export function getCreateRequestBody( +export const getCreateTransformRequestBody = ( indexPatternTitle: IndexPattern['title'], pivotState: StepDefineExposedState, transformDetailsState: StepDetailsExposedState -): CreateRequestBody { - const request: CreateRequestBody = { - ...getPreviewRequestBody( - indexPatternTitle, - getPivotQuery(pivotState.searchQuery), - dictionaryToArray(pivotState.groupByList), - dictionaryToArray(pivotState.aggList) - ), - // conditionally add optional description - ...(transformDetailsState.transformDescription !== '' - ? { description: transformDetailsState.transformDescription } - : {}), - dest: { - index: transformDetailsState.destinationIndex, - }, - // conditionally add continuous mode config - ...(transformDetailsState.isContinuousModeEnabled - ? { - sync: { - time: { - field: transformDetailsState.continuousModeDateField, - delay: transformDetailsState.continuousModeDelay, - }, +): PutTransformsRequestSchema => ({ + ...getPreviewTransformRequestBody( + indexPatternTitle, + getPivotQuery(pivotState.searchQuery), + dictionaryToArray(pivotState.groupByList), + dictionaryToArray(pivotState.aggList) + ), + // conditionally add optional description + ...(transformDetailsState.transformDescription !== '' + ? { description: transformDetailsState.transformDescription } + : {}), + dest: { + index: transformDetailsState.destinationIndex, + }, + // conditionally add continuous mode config + ...(transformDetailsState.isContinuousModeEnabled + ? { + sync: { + time: { + field: transformDetailsState.continuousModeDateField, + delay: transformDetailsState.continuousModeDelay, }, - } - : {}), - }; - - return request; + }, + } + : {}), +}); + +export function isHttpFetchError(error: any): error is HttpFetchError { + return ( + error instanceof HttpFetchError && + typeof error.name === 'string' && + typeof error.message !== 'undefined' + ); } diff --git a/x-pack/plugins/transform/public/app/common/transform.ts b/x-pack/plugins/transform/public/app/common/transform.ts index a02bed2fa65e7..b71bab62096b6 100644 --- a/x-pack/plugins/transform/public/app/common/transform.ts +++ b/x-pack/plugins/transform/public/app/common/transform.ts @@ -9,13 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { filter, distinctUntilChanged } from 'rxjs/operators'; import { Subscription } from 'rxjs'; -import { TransformId } from '../../../common'; - -import { PivotAggDict } from './pivot_aggs'; -import { PivotGroupByDict } from './pivot_group_by'; - -export type IndexName = string; -export type IndexPattern = string; +import { TransformId } from '../../../common/types/transform'; // Transform name must contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; // It must also start and end with an alphanumeric character. @@ -23,41 +17,6 @@ export function isTransformIdValid(transformId: TransformId) { return /^[a-z0-9\-\_]+$/g.test(transformId) && !/^([_-].*)?(.*[_-])?$/g.test(transformId); } -export interface PreviewRequestBody { - pivot: { - group_by: PivotGroupByDict; - aggregations: PivotAggDict; - }; - source: { - index: IndexPattern | IndexPattern[]; - query?: any; - }; -} - -export interface CreateRequestBody extends PreviewRequestBody { - description?: string; - dest: { - index: IndexName; - }; - frequency?: string; - settings?: { - max_page_search_size?: number; - docs_per_second?: number; - }; - sync?: { - time: { - field: string; - delay: string; - }; - }; -} - -export interface TransformPivotConfig extends CreateRequestBody { - id: TransformId; - create_time?: number; - version?: string; -} - export enum REFRESH_TRANSFORM_LIST_STATE { ERROR = 'error', IDLE = 'idle', diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index a2a762a7e2dfb..b32803fea1501 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -6,9 +6,8 @@ import { EuiTableActionsColumnType } from '@elastic/eui'; -import { TransformId } from '../../../common'; -import { TransformPivotConfig } from './transform'; -import { TransformStats } from './transform_stats'; +import { TransformId, TransformPivotConfig } from '../../../common/types/transform'; +import { TransformStats } from '../../../common/types/transform_stats'; // Used to pass on attribute names to table columns export enum TRANSFORM_LIST_COLUMN { diff --git a/x-pack/plugins/transform/public/app/common/transform_stats.ts b/x-pack/plugins/transform/public/app/common/transform_stats.ts index 72df6d3985e23..aaf7f97399d44 100644 --- a/x-pack/plugins/transform/public/app/common/transform_stats.ts +++ b/x-pack/plugins/transform/public/app/common/transform_stats.ts @@ -4,64 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TransformId, TRANSFORM_STATE } from '../../../common'; +import { TRANSFORM_STATE } from '../../../common/constants'; import { TransformListRow } from './transform_list'; -export enum TRANSFORM_MODE { - BATCH = 'batch', - CONTINUOUS = 'continuous', -} - -export interface TransformStats { - id: TransformId; - checkpointing: { - last: { - checkpoint: number; - timestamp_millis?: number; - }; - next?: { - checkpoint: number; - checkpoint_progress?: { - total_docs: number; - docs_remaining: number; - percent_complete: number; - }; - }; - operations_behind: number; - }; - node?: { - id: string; - name: string; - ephemeral_id: string; - transport_address: string; - attributes: Record; - }; - stats: { - documents_indexed: number; - documents_processed: number; - index_failures: number; - index_time_in_ms: number; - index_total: number; - pages_processed: number; - search_failures: number; - search_time_in_ms: number; - search_total: number; - trigger_count: number; - }; - reason?: string; - state: TRANSFORM_STATE; -} - -export function isTransformStats(arg: any): arg is TransformStats { - return ( - typeof arg === 'object' && - arg !== null && - {}.hasOwnProperty.call(arg, 'state') && - Object.values(TRANSFORM_STATE).includes(arg.state) - ); -} - export function getTransformProgress(item: TransformListRow) { if (isCompletedBatchTransform(item)) { return 100; diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index a5cccd58211c5..40a6ab2b65862 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -4,67 +4,162 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TransformId, TransformEndpointRequest } from '../../../../common'; +import { HttpFetchError } from 'kibana/public'; -import { PreviewRequestBody } from '../../common'; +import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; + +import { TransformId } from '../../../../common/types/transform'; +import type { FieldHistogramsResponseSchema } from '../../../../common/api_schemas/field_histograms'; +import type { GetTransformsAuditMessagesResponseSchema } from '../../../../common/api_schemas/audit_messages'; +import type { + DeleteTransformsRequestSchema, + DeleteTransformsResponseSchema, +} from '../../../../common/api_schemas/delete_transforms'; +import type { + StartTransformsRequestSchema, + StartTransformsResponseSchema, +} from '../../../../common/api_schemas/start_transforms'; +import type { + StopTransformsRequestSchema, + StopTransformsResponseSchema, +} from '../../../../common/api_schemas/stop_transforms'; +import type { + GetTransformsResponseSchema, + PostTransformsPreviewRequestSchema, + PostTransformsPreviewResponseSchema, + PutTransformsRequestSchema, + PutTransformsResponseSchema, +} from '../../../../common/api_schemas/transforms'; +import type { GetTransformsStatsResponseSchema } from '../../../../common/api_schemas/transforms_stats'; +import type { + PostTransformsUpdateRequestSchema, + PostTransformsUpdateResponseSchema, +} from '../../../../common/api_schemas/update_transforms'; + +import type { SearchResponse7 } from '../../../../common/shared_imports'; +import { EsIndex } from '../../../../common/types/es_index'; + +import type { SavedSearchQuery } from '../use_search_items'; + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} const apiFactory = () => ({ - getTransforms(transformId?: TransformId): Promise { - return new Promise((resolve, reject) => { - resolve([]); - }); + async getTransform( + transformId: TransformId + ): Promise { + return Promise.resolve({ count: 0, transforms: [] }); }, - getTransformsStats(transformId?: TransformId): Promise { - if (transformId !== undefined) { - return new Promise((resolve, reject) => { - resolve([]); - }); - } - - return new Promise((resolve, reject) => { - resolve([]); - }); + async getTransforms(): Promise { + return Promise.resolve({ count: 0, transforms: [] }); }, - createTransform(transformId: TransformId, transformConfig: any): Promise { - return new Promise((resolve, reject) => { - resolve([]); - }); + async getTransformStats( + transformId: TransformId + ): Promise { + return Promise.resolve({ count: 0, transforms: [] }); }, - deleteTransforms(transformsInfo: TransformEndpointRequest[]) { - return new Promise((resolve, reject) => { - resolve([]); - }); + async getTransformsStats(): Promise { + return Promise.resolve({ count: 0, transforms: [] }); }, - getTransformsPreview(obj: PreviewRequestBody): Promise { - return new Promise((resolve, reject) => { - resolve([]); - }); + async createTransform( + transformId: TransformId, + transformConfig: PutTransformsRequestSchema + ): Promise { + return Promise.resolve({ transformsCreated: [], errors: [] }); }, - startTransforms(transformsInfo: TransformEndpointRequest[]) { - return new Promise((resolve, reject) => { - resolve([]); + async updateTransform( + transformId: TransformId, + transformConfig: PostTransformsUpdateRequestSchema + ): Promise { + return Promise.resolve({ + id: 'the-test-id', + source: { index: ['the-index-name'], query: { match_all: {} } }, + dest: { index: 'user-the-destination-index-name' }, + frequency: '10m', + pivot: { + group_by: { the_group: { terms: { field: 'the-group-by-field' } } }, + aggregations: { the_agg: { value_count: { field: 'the-agg-field' } } }, + }, + description: 'the-description', + settings: { docs_per_second: null }, + version: '8.0.0', + create_time: 1598860879097, }); }, - stopTransforms(transformsInfo: TransformEndpointRequest[]) { - return new Promise((resolve, reject) => { - resolve([]); - }); + async deleteTransforms( + reqBody: DeleteTransformsRequestSchema + ): Promise { + return Promise.resolve({}); }, - getTransformAuditMessages(transformId: TransformId): Promise { - return new Promise((resolve, reject) => { - resolve([]); + async getTransformsPreview( + obj: PostTransformsPreviewRequestSchema + ): Promise { + return Promise.resolve({ + generated_dest_index: { + mappings: { + _meta: { + _transform: { + transform: 'the-transform', + version: { create: 'the-version' }, + creation_date_in_millis: 0, + }, + created_by: 'mock', + }, + properties: {}, + }, + settings: { index: { number_of_shards: '1', auto_expand_replicas: '0-1' } }, + aliases: {}, + }, + preview: [], }); }, - esSearch(payload: any) { - return new Promise((resolve, reject) => { - resolve([]); - }); + async startTransforms( + reqBody: StartTransformsRequestSchema + ): Promise { + return Promise.resolve({}); + }, + async stopTransforms( + transformsInfo: StopTransformsRequestSchema + ): Promise { + return Promise.resolve({}); }, - getIndices() { - return new Promise((resolve, reject) => { - resolve([]); + async getTransformAuditMessages( + transformId: TransformId + ): Promise { + return Promise.resolve([]); + }, + async esSearch(payload: any): Promise { + return Promise.resolve({ + hits: { + hits: [], + total: { + value: 0, + relation: 'the-relation', + }, + max_score: 0, + }, + timed_out: false, + took: 10, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, }); }, + + async getEsIndices(): Promise { + return Promise.resolve([]); + }, + async getHistogramsForFields( + indexPatternTitle: string, + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ): Promise { + return Promise.resolve([]); + }, }); export const useApi = () => { diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 1d2752b9e939d..4cff5dd9b648e 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -6,20 +6,43 @@ import { useMemo } from 'react'; +import { HttpFetchError } from 'kibana/public'; + import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; -import { - TransformId, - TransformEndpointRequest, - TransformEndpointResult, - DeleteTransformEndpointResult, -} from '../../../common'; +import type { GetTransformsAuditMessagesResponseSchema } from '../../../common/api_schemas/audit_messages'; +import type { + DeleteTransformsRequestSchema, + DeleteTransformsResponseSchema, +} from '../../../common/api_schemas/delete_transforms'; +import type { FieldHistogramsResponseSchema } from '../../../common/api_schemas/field_histograms'; +import type { + StartTransformsRequestSchema, + StartTransformsResponseSchema, +} from '../../../common/api_schemas/start_transforms'; +import type { + StopTransformsRequestSchema, + StopTransformsResponseSchema, +} from '../../../common/api_schemas/stop_transforms'; +import type { + GetTransformsResponseSchema, + PostTransformsPreviewRequestSchema, + PostTransformsPreviewResponseSchema, + PutTransformsRequestSchema, + PutTransformsResponseSchema, +} from '../../../common/api_schemas/transforms'; +import type { + PostTransformsUpdateRequestSchema, + PostTransformsUpdateResponseSchema, +} from '../../../common/api_schemas/update_transforms'; +import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats'; +import { TransformId } from '../../../common/types/transform'; import { API_BASE_PATH } from '../../../common/constants'; +import { EsIndex } from '../../../common/types/es_index'; +import type { SearchResponse7 } from '../../../common/shared_imports'; import { useAppDependencies } from '../app_dependencies'; -import { GetTransformsResponse, PreviewRequestBody } from '../common'; -import { EsIndex } from './use_api_types'; import { SavedSearchQuery } from './use_search_items'; // Default sampler shard size used for field histograms @@ -35,81 +58,146 @@ export const useApi = () => { return useMemo( () => ({ - getTransforms(transformId?: TransformId): Promise { - const transformIdString = transformId !== undefined ? `/${transformId}` : ''; - return http.get(`${API_BASE_PATH}transforms${transformIdString}`); + async getTransform( + transformId: TransformId + ): Promise { + try { + return await http.get(`${API_BASE_PATH}transforms/${transformId}`); + } catch (e) { + return e; + } }, - getTransformsStats(transformId?: TransformId): Promise { - if (transformId !== undefined) { - return http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`); + async getTransforms(): Promise { + try { + return await http.get(`${API_BASE_PATH}transforms`); + } catch (e) { + return e; } - - return http.get(`${API_BASE_PATH}transforms/_stats`); }, - createTransform(transformId: TransformId, transformConfig: any): Promise { - return http.put(`${API_BASE_PATH}transforms/${transformId}`, { - body: JSON.stringify(transformConfig), - }); + async getTransformStats( + transformId: TransformId + ): Promise { + try { + return await http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`); + } catch (e) { + return e; + } }, - updateTransform(transformId: TransformId, transformConfig: any): Promise { - return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, { - body: JSON.stringify(transformConfig), - }); + async getTransformsStats(): Promise { + try { + return await http.get(`${API_BASE_PATH}transforms/_stats`); + } catch (e) { + return e; + } }, - deleteTransforms( - transformsInfo: TransformEndpointRequest[], - deleteDestIndex: boolean | undefined, - deleteDestIndexPattern: boolean | undefined, - forceDelete: boolean - ): Promise { - return http.post(`${API_BASE_PATH}delete_transforms`, { - body: JSON.stringify({ - transformsInfo, - deleteDestIndex, - deleteDestIndexPattern, - forceDelete, - }), - }); + async createTransform( + transformId: TransformId, + transformConfig: PutTransformsRequestSchema + ): Promise { + try { + return await http.put(`${API_BASE_PATH}transforms/${transformId}`, { + body: JSON.stringify(transformConfig), + }); + } catch (e) { + return e; + } }, - getTransformsPreview(obj: PreviewRequestBody): Promise { - return http.post(`${API_BASE_PATH}transforms/_preview`, { - body: JSON.stringify(obj), - }); + async updateTransform( + transformId: TransformId, + transformConfig: PostTransformsUpdateRequestSchema + ): Promise { + try { + return await http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, { + body: JSON.stringify(transformConfig), + }); + } catch (e) { + return e; + } }, - startTransforms( - transformsInfo: TransformEndpointRequest[] - ): Promise { - return http.post(`${API_BASE_PATH}start_transforms`, { - body: JSON.stringify(transformsInfo), - }); + async deleteTransforms( + reqBody: DeleteTransformsRequestSchema + ): Promise { + try { + return await http.post(`${API_BASE_PATH}delete_transforms`, { + body: JSON.stringify(reqBody), + }); + } catch (e) { + return e; + } }, - stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise { - return http.post(`${API_BASE_PATH}stop_transforms`, { - body: JSON.stringify(transformsInfo), - }); + async getTransformsPreview( + obj: PostTransformsPreviewRequestSchema + ): Promise { + try { + return await http.post(`${API_BASE_PATH}transforms/_preview`, { + body: JSON.stringify(obj), + }); + } catch (e) { + return e; + } }, - getTransformAuditMessages(transformId: TransformId): Promise { - return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`); + async startTransforms( + reqBody: StartTransformsRequestSchema + ): Promise { + try { + return await http.post(`${API_BASE_PATH}start_transforms`, { + body: JSON.stringify(reqBody), + }); + } catch (e) { + return e; + } + }, + async stopTransforms( + transformsInfo: StopTransformsRequestSchema + ): Promise { + try { + return await http.post(`${API_BASE_PATH}stop_transforms`, { + body: JSON.stringify(transformsInfo), + }); + } catch (e) { + return e; + } + }, + async getTransformAuditMessages( + transformId: TransformId + ): Promise { + try { + return await http.get(`${API_BASE_PATH}transforms/${transformId}/messages`); + } catch (e) { + return e; + } }, - esSearch(payload: any): Promise { - return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); + async esSearch(payload: any): Promise { + try { + return await http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); + } catch (e) { + return e; + } }, - getIndices(): Promise { - return http.get(`/api/index_management/indices`); + async getEsIndices(): Promise { + try { + return await http.get(`/api/index_management/indices`); + } catch (e) { + return e; + } }, - getHistogramsForFields( + async getHistogramsForFields( indexPatternTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE - ) { - return http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { - body: JSON.stringify({ - query, - fields, - samplerShardSize, - }), - }); + ): Promise { + try { + return await http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + body: JSON.stringify({ + query, + fields, + samplerShardSize, + }), + }); + } catch (e) { + return e; + } }, }), [http] diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index fdf77c8ebee51..1a97ba7806fef 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -7,11 +7,11 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { - DeleteTransformEndpointResult, +import type { DeleteTransformStatus, - TransformEndpointRequest, -} from '../../../common'; + DeleteTransformsRequestSchema, +} from '../../../common/api_schemas/delete_transforms'; +import { isDeleteTransformsResponseSchema } from '../../../common/api_schemas/type_guards'; import { extractErrorMessage } from '../../shared_imports'; import { getErrorMessage } from '../../../common/utils/errors'; import { useAppDependencies, useToastNotifications } from '../app_dependencies'; @@ -109,173 +109,157 @@ export const useDeleteTransforms = () => { const toastNotifications = useToastNotifications(); const api = useApi(); - return async ( - transforms: TransformListRow[], - shouldDeleteDestIndex: boolean, - shouldDeleteDestIndexPattern: boolean, - shouldForceDelete = false - ) => { - const transformsInfo: TransformEndpointRequest[] = transforms.map((tf) => ({ - id: tf.config.id, - state: tf.stats.state, - })); + return async (reqBody: DeleteTransformsRequestSchema) => { + const results = await api.deleteTransforms(reqBody); - try { - const results: DeleteTransformEndpointResult = await api.deleteTransforms( - transformsInfo, - shouldDeleteDestIndex, - shouldDeleteDestIndexPattern, - shouldForceDelete - ); - const isBulk = Object.keys(results).length > 1; - const successCount: Record = { - transformDeleted: 0, - destIndexDeleted: 0, - destIndexPatternDeleted: 0, - }; - for (const transformId in results) { - // hasOwnProperty check to ensure only properties on object itself, and not its prototypes - if (results.hasOwnProperty(transformId)) { - const status = results[transformId]; - const destinationIndex = status.destinationIndex; + if (!isDeleteTransformsResponseSchema(results)) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.transformList.deleteTransformGenericErrorMessage', { + defaultMessage: 'An error occurred calling the API endpoint to delete transforms.', + }), + text: toMountPoint( + + ), + }); + return; + } - // if we are only deleting one transform, show the success toast messages - if (!isBulk && status.transformDeleted) { - if (status.transformDeleted?.success) { - toastNotifications.addSuccess( - i18n.translate('xpack.transform.transformList.deleteTransformSuccessMessage', { - defaultMessage: 'Request to delete transform {transformId} acknowledged.', - values: { transformId }, - }) - ); - } - if (status.destIndexDeleted?.success) { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage', - { - defaultMessage: - 'Request to delete destination index {destinationIndex} acknowledged.', - values: { destinationIndex }, - } - ) - ); - } - if (status.destIndexPatternDeleted?.success) { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage', - { - defaultMessage: - 'Request to delete index pattern {destinationIndex} acknowledged.', - values: { destinationIndex }, - } - ) - ); - } - } else { - (Object.keys(successCount) as SuccessCountField[]).forEach((key) => { - if (status[key]?.success) { - successCount[key] = successCount[key] + 1; - } - }); - } - if (status.transformDeleted?.error) { - const error = extractErrorMessage(status.transformDeleted.error); - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.transformList.deleteTransformErrorMessage', { - defaultMessage: 'An error occurred deleting the transform {transformId}', + const isBulk = Object.keys(results).length > 1; + const successCount: Record = { + transformDeleted: 0, + destIndexDeleted: 0, + destIndexPatternDeleted: 0, + }; + for (const transformId in results) { + // hasOwnProperty check to ensure only properties on object itself, and not its prototypes + if (results.hasOwnProperty(transformId)) { + const status = results[transformId]; + const destinationIndex = status.destinationIndex; + + // if we are only deleting one transform, show the success toast messages + if (!isBulk && status.transformDeleted) { + if (status.transformDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.deleteTransformSuccessMessage', { + defaultMessage: 'Request to delete transform {transformId} acknowledged.', values: { transformId }, - }), - text: toMountPoint( - - ), - }); + }) + ); } - - if (status.destIndexDeleted?.error) { - const error = extractErrorMessage(status.destIndexDeleted.error); - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage', + if (status.destIndexDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage', { - defaultMessage: 'An error occurred deleting destination index {destinationIndex}', + defaultMessage: + 'Request to delete destination index {destinationIndex} acknowledged.', values: { destinationIndex }, } - ), - text: toMountPoint( - - ), - }); + ) + ); } - - if (status.destIndexPatternDeleted?.error) { - const error = extractErrorMessage(status.destIndexPatternDeleted.error); - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage', + if (status.destIndexPatternDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage', { - defaultMessage: 'An error occurred deleting index pattern {destinationIndex}', + defaultMessage: + 'Request to delete index pattern {destinationIndex} acknowledged.', values: { destinationIndex }, } - ), - text: toMountPoint( - - ), - }); + ) + ); } + } else { + (Object.keys(successCount) as SuccessCountField[]).forEach((key) => { + if (status[key]?.success) { + successCount[key] = successCount[key] + 1; + } + }); } - } - - // if we are deleting multiple transforms, combine the success messages - if (isBulk) { - if (successCount.transformDeleted > 0) { - toastNotifications.addSuccess( - i18n.translate('xpack.transform.transformList.bulkDeleteTransformSuccessMessage', { - defaultMessage: - 'Successfully deleted {count} {count, plural, one {transform} other {transforms}}.', - values: { count: successCount.transformDeleted }, - }) - ); + if (status.transformDeleted?.error) { + const error = extractErrorMessage(status.transformDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.transformList.deleteTransformErrorMessage', { + defaultMessage: 'An error occurred deleting the transform {transformId}', + values: { transformId }, + }), + text: toMountPoint( + + ), + }); } - if (successCount.destIndexDeleted > 0) { - toastNotifications.addSuccess( - i18n.translate('xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage', { - defaultMessage: - 'Successfully deleted {count} destination {count, plural, one {index} other {indices}}.', - values: { count: successCount.destIndexDeleted }, - }) - ); + if (status.destIndexDeleted?.error) { + const error = extractErrorMessage(status.destIndexDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage', + { + defaultMessage: 'An error occurred deleting destination index {destinationIndex}', + values: { destinationIndex }, + } + ), + text: toMountPoint( + + ), + }); } - if (successCount.destIndexPatternDeleted > 0) { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage', + + if (status.destIndexPatternDeleted?.error) { + const error = extractErrorMessage(status.destIndexPatternDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage', { - defaultMessage: - 'Successfully deleted {count} destination index {count, plural, one {pattern} other {patterns}}.', - values: { count: successCount.destIndexPatternDeleted }, + defaultMessage: 'An error occurred deleting index pattern {destinationIndex}', + values: { destinationIndex }, } - ) - ); + ), + text: toMountPoint( + + ), + }); } } + } - refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.transformList.deleteTransformGenericErrorMessage', { - defaultMessage: 'An error occurred calling the API endpoint to delete transforms.', - }), - text: toMountPoint( - - ), - }); + // if we are deleting multiple transforms, combine the success messages + if (isBulk) { + if (successCount.transformDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.bulkDeleteTransformSuccessMessage', { + defaultMessage: + 'Successfully deleted {count} {count, plural, one {transform} other {transforms}}.', + values: { count: successCount.transformDeleted }, + }) + ); + } + + if (successCount.destIndexDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage', { + defaultMessage: + 'Successfully deleted {count} destination {count, plural, one {index} other {indices}}.', + values: { count: successCount.destIndexDeleted }, + }) + ); + } + if (successCount.destIndexPatternDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage', { + defaultMessage: + 'Successfully deleted {count} destination index {count, plural, one {pattern} other {patterns}}.', + values: { count: successCount.destIndexPatternDeleted }, + }) + ); + } } + + refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); }; }; diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index bd19a7f8bf4d8..5f3a9a6abfdb4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -4,52 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - TransformListRow, - TransformStats, - TRANSFORM_MODE, - isTransformStats, - TransformPivotConfig, - refreshTransformList$, - REFRESH_TRANSFORM_LIST_STATE, -} from '../common'; - -import { useApi } from './use_api'; +import { HttpFetchError } from 'src/core/public'; -interface GetTransformsResponse { - count: number; - transforms: TransformPivotConfig[]; -} - -interface GetTransformsStatsResponseOk { - node_failures?: object; - count: number; - transforms: TransformStats[]; -} - -const isGetTransformsStatsResponseOk = (arg: any): arg is GetTransformsStatsResponseOk => { - return ( - {}.hasOwnProperty.call(arg, 'count') && - {}.hasOwnProperty.call(arg, 'transforms') && - Array.isArray(arg.transforms) - ); -}; +import { + isGetTransformsResponseSchema, + isGetTransformsStatsResponseSchema, +} from '../../../common/api_schemas/type_guards'; +import { TRANSFORM_MODE } from '../../../common/constants'; +import { isTransformStats } from '../../../common/types/transform_stats'; -interface GetTransformsStatsResponseError { - statusCode: number; - error: string; - message: string; -} +import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; -type GetTransformsStatsResponse = GetTransformsStatsResponseOk | GetTransformsStatsResponseError; +import { useApi } from './use_api'; export type GetTransforms = (forceRefresh?: boolean) => void; export const useGetTransforms = ( setTransforms: React.Dispatch>, - setErrorMessage: React.Dispatch< - React.SetStateAction - >, + setErrorMessage: React.Dispatch>, setIsInitialized: React.Dispatch>, blockRefresh: boolean ): GetTransforms => { @@ -66,45 +38,57 @@ export const useGetTransforms = ( return; } - try { - const transformConfigs: GetTransformsResponse = await api.getTransforms(); - const transformStats: GetTransformsStatsResponse = await api.getTransformsStats(); - - const tableRows = transformConfigs.transforms.reduce((reducedtableRows, config) => { - const stats = isGetTransformsStatsResponseOk(transformStats) - ? transformStats.transforms.find((d) => config.id === d.id) - : undefined; - - // A newly created transform might not have corresponding stats yet. - // If that's the case we just skip the transform and don't add it to the transform list yet. - if (!isTransformStats(stats)) { - return reducedtableRows; - } - - // Table with expandable rows requires `id` on the outer most level - reducedtableRows.push({ - id: config.id, - config, - mode: - typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, - stats, - }); - return reducedtableRows; - }, [] as TransformListRow[]); + const transformConfigs = await api.getTransforms(); + const transformStats = await api.getTransformsStats(); - setTransforms(tableRows); - setErrorMessage(undefined); - setIsInitialized(true); - refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE); - } catch (e) { + if ( + !isGetTransformsResponseSchema(transformConfigs) || + !isGetTransformsStatsResponseSchema(transformStats) + ) { // An error is followed immediately by setting the state to idle. // This way we're able to treat ERROR as a one-time-event like REFRESH. refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.ERROR); refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE); setTransforms([]); - setErrorMessage(e); + setIsInitialized(true); + + if (!isGetTransformsResponseSchema(transformConfigs)) { + setErrorMessage(transformConfigs); + } else if (!isGetTransformsStatsResponseSchema(transformStats)) { + setErrorMessage(transformStats); + } + + return; } + + const tableRows = transformConfigs.transforms.reduce((reducedtableRows, config) => { + const stats = isGetTransformsStatsResponseSchema(transformStats) + ? transformStats.transforms.find((d) => config.id === d.id) + : undefined; + + // A newly created transform might not have corresponding stats yet. + // If that's the case we just skip the transform and don't add it to the transform list yet. + if (!isTransformStats(stats)) { + return reducedtableRows; + } + + // Table with expandable rows requires `id` on the outer most level + reducedtableRows.push({ + id: config.id, + config, + mode: + typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, + stats, + }); + return reducedtableRows; + }, [] as TransformListRow[]); + + setTransforms(tableRows); + setErrorMessage(undefined); + setIsInitialized(true); + refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE); + concurrentLoads--; if (concurrentLoads > 0) { diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 946f7991d049d..ce233d0cf7caa 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -8,6 +8,11 @@ import { useEffect } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; +import { + isEsSearchResponse, + isFieldHistogramsResponseSchema, +} from '../../../common/api_schemas/type_guards'; + import { getFieldType, getDataGridSchemaFromKibanaFieldType, @@ -16,7 +21,6 @@ import { useDataGrid, useRenderCellValue, EsSorting, - SearchResponse7, UseIndexDataReturnType, INDEX_STATUS, } from '../../shared_imports'; @@ -29,8 +33,6 @@ import { useApi } from './use_api'; import { useToastNotifications } from '../app_dependencies'; -type IndexSearchResponse = SearchResponse7; - export const useIndexData = ( indexPattern: SearchItems['indexPattern'], query: PivotQuery @@ -90,37 +92,39 @@ export const useIndexData = ( }, }; - try { - const resp: IndexSearchResponse = await api.esSearch(esSearchRequest); - - const docs = resp.hits.hits.map((d) => d._source); + const resp = await api.esSearch(esSearchRequest); - setRowCount(resp.hits.total.value); - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); - } catch (e) { - setErrorMessage(getErrorMessage(e)); + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); setStatus(INDEX_STATUS.ERROR); return; } + + const docs = resp.hits.hits.map((d) => d._source); + + setRowCount(resp.hits.total.value); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); }; const fetchColumnChartsData = async function () { - try { - const columnChartsData = await api.getHistogramsForFields( - indexPattern.title, - columns - .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) - .map((cT) => ({ - fieldName: cT.id, - type: getFieldType(cT.schema), - })), - isDefaultQuery(query) ? matchAllQuery : query - ); - setColumnCharts(columnChartsData); - } catch (e) { - showDataGridColumnChartErrorMessageToast(e, toastNotifications); + const columnChartsData = await api.getHistogramsForFields( + indexPattern.title, + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + isDefaultQuery(query) ? matchAllQuery : query + ); + + if (!isFieldHistogramsResponseSchema(columnChartsData)) { + showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); + return; } + + setColumnCharts(columnChartsData); }; useEffect(() => { diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index a0e7c5dde494a..c51bf7d7e6741 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -13,11 +13,13 @@ import { i18n } from '@kbn/i18n'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; +import type { PreviewMappingsProperties } from '../../../common/api_schemas/transforms'; +import { isPostTransformsPreviewResponseSchema } from '../../../common/api_schemas/type_guards'; import { dictionaryToArray } from '../../../common/types/common'; -import { formatHumanReadableDateTimeSeconds } from '../../shared_imports'; import { getNestedProperty } from '../../../common/utils/object_utils'; import { + formatHumanReadableDateTimeSeconds, multiColumnSortFactory, useDataGrid, RenderCellValue, @@ -27,12 +29,11 @@ import { import { getErrorMessage } from '../../../common/utils/errors'; import { - getPreviewRequestBody, + getPreviewTransformRequestBody, PivotAggsConfigDict, PivotGroupByConfigDict, PivotGroupByConfig, PivotQuery, - PreviewMappings, PivotAggsConfig, } from '../common'; @@ -74,21 +75,23 @@ export const usePivotData = ( aggs: PivotAggsConfigDict, groupBy: PivotGroupByConfigDict ): UseIndexDataReturnType => { - const [previewMappings, setPreviewMappings] = useState({ properties: {} }); + const [previewMappingsProperties, setPreviewMappingsProperties] = useState< + PreviewMappingsProperties + >({}); const api = useApi(); const aggsArr = useMemo(() => dictionaryToArray(aggs), [aggs]); const groupByArr = useMemo(() => dictionaryToArray(groupBy), [groupBy]); // Filters mapping properties of type `object`, which get returned for nested field parents. - const columnKeys = Object.keys(previewMappings.properties).filter( - (key) => previewMappings.properties[key].type !== 'object' + const columnKeys = Object.keys(previewMappingsProperties).filter( + (key) => previewMappingsProperties[key].type !== 'object' ); columnKeys.sort(sortColumns(groupByArr)); // EuiDataGrid State const columns: EuiDataGridColumn[] = columnKeys.map((id) => { - const field = previewMappings.properties[id]; + const field = previewMappingsProperties[id]; // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] // To fall back to the default string schema it needs to be undefined. @@ -159,28 +162,35 @@ export const usePivotData = ( setNoDataMessage(''); setStatus(INDEX_STATUS.LOADING); - try { - const previewRequest = getPreviewRequestBody(indexPatternTitle, query, groupByArr, aggsArr); - const resp = await api.getTransformsPreview(previewRequest); - setTableItems(resp.preview); - setRowCount(resp.preview.length); - setPreviewMappings(resp.generated_dest_index.mappings); - setStatus(INDEX_STATUS.LOADED); - - if (resp.preview.length === 0) { - setNoDataMessage( - i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', { - defaultMessage: - 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', - }) - ); - } - } catch (e) { - setErrorMessage(getErrorMessage(e)); + const previewRequest = getPreviewTransformRequestBody( + indexPatternTitle, + query, + groupByArr, + aggsArr + ); + const resp = await api.getTransformsPreview(previewRequest); + + if (!isPostTransformsPreviewResponseSchema(resp)) { + setErrorMessage(getErrorMessage(resp)); setTableItems([]); setRowCount(0); - setPreviewMappings({ properties: {} }); + setPreviewMappingsProperties({}); setStatus(INDEX_STATUS.ERROR); + return; + } + + setTableItems(resp.preview); + setRowCount(resp.preview.length); + setPreviewMappingsProperties(resp.generated_dest_index.mappings.properties); + setStatus(INDEX_STATUS.LOADED); + + if (resp.preview.length === 0) { + setNoDataMessage( + i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', { + defaultMessage: + 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', + }) + ); } }; @@ -236,19 +246,19 @@ export const usePivotData = ( if ( [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS].includes( - previewMappings.properties[columnId].type + previewMappingsProperties[columnId].type ) ) { return formatHumanReadableDateTimeSeconds(moment(cellValue).unix() * 1000); } - if (previewMappings.properties[columnId].type === ES_FIELD_TYPES.BOOLEAN) { + if (previewMappingsProperties[columnId].type === ES_FIELD_TYPES.BOOLEAN) { return cellValue ? 'true' : 'false'; } return cellValue; }; - }, [pageData, pagination.pageIndex, pagination.pageSize, previewMappings.properties]); + }, [pageData, pagination.pageIndex, pagination.pageSize, previewMappingsProperties]); return { ...dataGrid, diff --git a/x-pack/plugins/transform/public/app/hooks/use_start_transform.ts b/x-pack/plugins/transform/public/app/hooks/use_start_transform.tsx similarity index 52% rename from x-pack/plugins/transform/public/app/hooks/use_start_transform.ts rename to x-pack/plugins/transform/public/app/hooks/use_start_transform.tsx index a0ffe1fdfa336..71ed220b6b4df 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_start_transform.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_start_transform.tsx @@ -4,25 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; + import { i18n } from '@kbn/i18n'; -import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +import type { StartTransformsRequestSchema } from '../../../common/api_schemas/start_transforms'; +import { isStartTransformsResponseSchema } from '../../../common/api_schemas/type_guards'; + +import { getErrorMessage } from '../../../common/utils/errors'; -import { useToastNotifications } from '../app_dependencies'; -import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; +import { useAppDependencies, useToastNotifications } from '../app_dependencies'; +import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; +import { ToastNotificationText } from '../components'; import { useApi } from './use_api'; export const useStartTransforms = () => { + const deps = useAppDependencies(); const toastNotifications = useToastNotifications(); const api = useApi(); - return async (transforms: TransformListRow[]) => { - const transformsInfo: TransformEndpointRequest[] = transforms.map((tf) => ({ - id: tf.config.id, - state: tf.stats.state, - })); - const results: TransformEndpointResult = await api.startTransforms(transformsInfo); + return async (transformsInfo: StartTransformsRequestSchema) => { + const results = await api.startTransforms(transformsInfo); + + if (!isStartTransformsResponseSchema(results)) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage', + { + defaultMessage: 'An error occurred calling the start transforms request.', + } + ), + text: toMountPoint( + + ), + }); + return; + } for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes diff --git a/x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts b/x-pack/plugins/transform/public/app/hooks/use_stop_transform.tsx similarity index 53% rename from x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts rename to x-pack/plugins/transform/public/app/hooks/use_stop_transform.tsx index 0df9834647704..be223c5eddfdd 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_stop_transform.tsx @@ -4,25 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; + import { i18n } from '@kbn/i18n'; -import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +import type { StopTransformsRequestSchema } from '../../../common/api_schemas/stop_transforms'; +import { isStopTransformsResponseSchema } from '../../../common/api_schemas/type_guards'; + +import { getErrorMessage } from '../../../common/utils/errors'; -import { useToastNotifications } from '../app_dependencies'; -import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; +import { useAppDependencies, useToastNotifications } from '../app_dependencies'; +import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; +import { ToastNotificationText } from '../components'; import { useApi } from './use_api'; export const useStopTransforms = () => { + const deps = useAppDependencies(); const toastNotifications = useToastNotifications(); const api = useApi(); - return async (transforms: TransformListRow[]) => { - const transformsInfo: TransformEndpointRequest[] = transforms.map((df) => ({ - id: df.config.id, - state: df.stats.state, - })); - const results: TransformEndpointResult = await api.stopTransforms(transformsInfo); + return async (transformsInfo: StopTransformsRequestSchema) => { + const results = await api.stopTransforms(transformsInfo); + + if (!isStopTransformsResponseSchema(results)) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.transformList.stopTransformResponseSchemaErrorMessage', + { + defaultMessage: 'An error occurred called the stop transforms request.', + } + ), + text: toMountPoint( + + ), + }); + return; + } for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx index 6553d4474d392..790fcaf5fa83c 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx @@ -6,7 +6,7 @@ import React, { createContext } from 'react'; -import { Privileges } from '../../../../../common'; +import { Privileges } from '../../../../../common/types/privileges'; import { useRequest } from '../../../hooks'; diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts index 282a737d0bf1e..841c6ed01766a 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; -import { Privileges } from '../../../../../common'; +import { Privileges } from '../../../../../common/types/privileges'; export interface Capabilities { canGetTransform: boolean; diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx index 89c6ac3a054f7..beeacc76bdc95 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx @@ -10,7 +10,7 @@ import { EuiPageContent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MissingPrivileges } from '../../../../../common'; +import { MissingPrivileges } from '../../../../../common/types/privileges'; import { SectionLoading } from '../../../components'; diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 19ba31d36e6e9..9a97c66bfb10b 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -21,40 +21,20 @@ import { EuiTitle, } from '@elastic/eui'; +import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; +import { TransformPivotConfig } from '../../../../common/types/transform'; + +import { isHttpFetchError } from '../../common/request'; import { useApi } from '../../hooks/use_api'; import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useSearchItems } from '../../hooks/use_search_items'; -import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; - import { useAppDependencies } from '../../app_dependencies'; -import { TransformPivotConfig } from '../../common'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; import { Wizard } from '../create_transform/components/wizard'; -interface GetTransformsResponseOk { - count: number; - transforms: TransformPivotConfig[]; -} - -interface GetTransformsResponseError { - error: { - msg: string; - path: string; - query: any; - statusCode: number; - response: string; - }; -} - -function isGetTransformsResponseError(arg: any): arg is GetTransformsResponseError { - return arg.error !== undefined; -} - -type GetTransformsResponse = GetTransformsResponseOk | GetTransformsResponseError; - type Props = RouteComponentProps<{ transformId: string }>; export const CloneTransformSection: FC = ({ match }) => { // Set breadcrumb and page title @@ -84,15 +64,15 @@ export const CloneTransformSection: FC = ({ match }) => { } = useSearchItems(undefined); const fetchTransformConfig = async () => { - try { - const transformConfigs: GetTransformsResponse = await api.getTransforms(transformId); - if (isGetTransformsResponseError(transformConfigs)) { - setTransformConfig(undefined); - setErrorMessage(transformConfigs.error.msg); - setIsInitialized(true); - return; - } + const transformConfigs = await api.getTransform(transformId); + if (isHttpFetchError(transformConfigs)) { + setTransformConfig(undefined); + setErrorMessage(transformConfigs.message); + setIsInitialized(true); + return; + } + try { await loadIndexPatterns(savedObjectsClient, indexPatterns); const indexPatternTitle = Array.isArray(transformConfigs.transforms[0].source.index) ? transformConfigs.transforms[0].source.index.join(',') diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx index fa0fe7bdf6126..49d59706befb8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx @@ -7,7 +7,10 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { AggName, PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from '../../../../common'; +import { AggName } from '../../../../../../common/types/aggregations'; +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + +import { PivotAggsConfig } from '../../../../common'; import { AggLabelForm } from './agg_label_form'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx index e50ba9e137331..4e5e3f71cd6e2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx @@ -10,8 +10,9 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiTextColor } from '@elastic/eui'; +import { AggName } from '../../../../../../common/types/aggregations'; + import { - AggName, isPivotAggsConfigWithUiSupport, PivotAggsConfig, PivotAggsConfigWithUiSupportDict, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx index 32c7ca5972e00..93de3d4fcfc9f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx @@ -7,7 +7,9 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from '../../../../common'; +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + +import { PivotAggsConfig } from '../../../../common'; import { AggListForm, AggListProps } from './list_form'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx index a02f4455250d7..f6ae1f292b0e6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx @@ -8,8 +8,9 @@ import React, { Fragment } from 'react'; import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { AggName } from '../../../../../../common/types/aggregations'; + import { - AggName, PivotAggsConfig, PivotAggsConfigDict, PivotAggsConfigWithUiSupportDict, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx index 923d52ba5cec1..8c644c358e658 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx @@ -7,7 +7,9 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from '../../../../common'; +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + +import { PivotAggsConfig } from '../../../../common'; import { AggListSummary, AggListSummaryProps } from './list_summary'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx index 7d07d79e7d283..fb6e141a54b04 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx @@ -8,7 +8,9 @@ import React, { Fragment } from 'react'; import { EuiForm, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { AggName, PivotAggsConfigDict } from '../../../../common'; +import { AggName } from '../../../../../../common/types/aggregations'; + +import { PivotAggsConfigDict } from '../../../../common'; export interface AggListSummaryProps { list: PivotAggsConfigDict; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx index b3e770a269681..8f2fbfb7084e6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx @@ -7,7 +7,10 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { AggName, PIVOT_SUPPORTED_AGGS, PivotAggsConfig } from '../../../../common'; +import { AggName } from '../../../../../../common/types/aggregations'; +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + +import { PivotAggsConfig } from '../../../../common'; import { PopoverForm } from './popover_form'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 50064274cf98e..30e8c2b594db7 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -20,10 +20,14 @@ import { import { cloneDeep } from 'lodash'; import { useUpdateEffect } from 'react-use'; +import { AggName } from '../../../../../../common/types/aggregations'; import { dictionaryToArray } from '../../../../../../common/types/common'; +import { + PivotSupportedAggs, + PIVOT_SUPPORTED_AGGS, +} from '../../../../../../common/types/pivot_aggs'; import { - AggName, isAggName, isPivotAggsConfigPercentiles, isPivotAggsConfigWithUiSupport, @@ -31,9 +35,8 @@ import { PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAggsConfig, PivotAggsConfigWithUiSupportDict, - PIVOT_SUPPORTED_AGGS, } from '../../../../common'; -import { isPivotAggsWithExtendedForm, PivotSupportedAggs } from '../../../../common/pivot_aggs'; +import { isPivotAggsWithExtendedForm } from '../../../../common/pivot_aggs'; import { getAggFormConfig } from '../step_define/common/get_agg_form_config'; interface Props { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx index c79da06ac8080..ff66ed6779e14 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx @@ -10,8 +10,9 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiTextColor } from '@elastic/eui'; +import { AggName } from '../../../../../../common/types/aggregations'; + import { - AggName, isGroupByDateHistogram, isGroupByHistogram, PivotGroupByConfig, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx index 2dc1a4332f6ad..a60989c76ab13 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx @@ -8,8 +8,9 @@ import React, { Fragment } from 'react'; import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { AggName } from '../../../../../../common/types/aggregations'; + import { - AggName, PivotGroupByConfig, PivotGroupByConfigDict, PivotGroupByConfigWithUiSupportDict, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx index 090f3b19f47fb..13829222f11f5 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx @@ -7,7 +7,9 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { AggName, PIVOT_SUPPORTED_GROUP_BY_AGGS, PivotGroupByConfig } from '../../../../common'; +import { AggName } from '../../../../../../common/types/aggregations'; + +import { PIVOT_SUPPORTED_GROUP_BY_AGGS, PivotGroupByConfig } from '../../../../common'; import { isIntervalValid, PopoverForm } from './popover_form'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx index 0452638e90dfb..f0a96fa6ab875 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx @@ -18,10 +18,10 @@ import { EuiSpacer, } from '@elastic/eui'; +import { AggName } from '../../../../../../common/types/aggregations'; import { dictionaryToArray } from '../../../../../../common/types/common'; import { - AggName, dateHistogramIntervalFormatRegex, getEsAggFromGroupByConfig, isGroupByDateHistogram, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 2fa1b7c713370..675bd0f9f88ed 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -30,6 +30,12 @@ import { import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import type { PutTransformsResponseSchema } from '../../../../../../common/api_schemas/transforms'; +import { + isGetTransformsStatsResponseSchema, + isPutTransformsResponseSchema, + isStartTransformsResponseSchema, +} from '../../../../../../common/api_schemas/type_guards'; import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants'; import { getErrorMessage } from '../../../../../../common/utils/errors'; @@ -93,34 +99,28 @@ export const StepCreateForm: FC = React.memo( async function createTransform() { setLoading(true); - try { - const resp = await api.createTransform(transformId, transformConfig); - if (resp.errors !== undefined && Array.isArray(resp.errors)) { - if (resp.errors.length === 1) { - throw resp.errors[0]; - } + const resp = await api.createTransform(transformId, transformConfig); - if (resp.errors.length > 1) { - throw resp.errors; - } + if (!isPutTransformsResponseSchema(resp) || resp.errors.length > 0) { + let respErrors: + | PutTransformsResponseSchema['errors'] + | PutTransformsResponseSchema['errors'][number] + | undefined; + + if (isPutTransformsResponseSchema(resp) && resp.errors.length > 0) { + respErrors = resp.errors.length === 1 ? resp.errors[0] : resp.errors; } - toastNotifications.addSuccess( - i18n.translate('xpack.transform.stepCreateForm.createTransformSuccessMessage', { - defaultMessage: 'Request to create transform {transformId} acknowledged.', - values: { transformId }, - }) - ); - setCreated(true); - setLoading(false); - } catch (e) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepCreateForm.createTransformErrorMessage', { defaultMessage: 'An error occurred creating the transform {transformId}:', values: { transformId }, }), text: toMountPoint( - + ), }); setCreated(false); @@ -128,6 +128,15 @@ export const StepCreateForm: FC = React.memo( return false; } + toastNotifications.addSuccess( + i18n.translate('xpack.transform.stepCreateForm.createTransformSuccessMessage', { + defaultMessage: 'Request to create transform {transformId} acknowledged.', + values: { transformId }, + }) + ); + setCreated(true); + setLoading(false); + if (createIndexPattern) { createKibanaIndexPattern(); } @@ -138,37 +147,36 @@ export const StepCreateForm: FC = React.memo( async function startTransform() { setLoading(true); - try { - const resp = await api.startTransforms([{ id: transformId }]); - if (typeof resp === 'object' && resp !== null && resp[transformId]?.success === true) { - toastNotifications.addSuccess( - i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', { - defaultMessage: 'Request to start transform {transformId} acknowledged.', - values: { transformId }, - }) - ); - setStarted(true); - setLoading(false); - } else { - const errorMessage = - typeof resp === 'object' && resp !== null && resp[transformId]?.success === false - ? resp[transformId].error - : resp; - throw new Error(errorMessage); - } - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepCreateForm.startTransformErrorMessage', { - defaultMessage: 'An error occurred starting the transform {transformId}:', + const resp = await api.startTransforms([{ id: transformId }]); + + if (isStartTransformsResponseSchema(resp) && resp[transformId]?.success === true) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', { + defaultMessage: 'Request to start transform {transformId} acknowledged.', values: { transformId }, - }), - text: toMountPoint( - - ), - }); - setStarted(false); + }) + ); + setStarted(true); setLoading(false); + return; } + + const errorMessage = + isStartTransformsResponseSchema(resp) && resp[transformId]?.success === false + ? resp[transformId].error + : resp; + + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.stepCreateForm.startTransformErrorMessage', { + defaultMessage: 'An error occurred starting the transform {transformId}:', + values: { transformId }, + }), + text: toMountPoint( + + ), + }); + setStarted(false); + setLoading(false); } async function createAndStartTransform() { @@ -250,27 +258,30 @@ export const StepCreateForm: FC = React.memo( // wrapping in function so we can keep the interval id in local scope function startProgressBar() { const interval = setInterval(async () => { - try { - const stats = await api.getTransformsStats(transformId); - if (stats && Array.isArray(stats.transforms) && stats.transforms.length > 0) { - const percent = - getTransformProgress({ - id: transformConfig.id, - config: transformConfig, - stats: stats.transforms[0], - }) || 0; - setProgressPercentComplete(percent); - if (percent >= 100) { - clearInterval(interval); - } + const stats = await api.getTransformStats(transformId); + + if ( + isGetTransformsStatsResponseSchema(stats) && + Array.isArray(stats.transforms) && + stats.transforms.length > 0 + ) { + const percent = + getTransformProgress({ + id: transformConfig.id, + config: transformConfig, + stats: stats.transforms[0], + }) || 0; + setProgressPercentComplete(percent); + if (percent >= 100) { + clearInterval(interval); } - } catch (e) { + } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepCreateForm.progressErrorMessage', { defaultMessage: 'An error occurred getting the progress percentage:', }), text: toMountPoint( - + ), }); clearInterval(interval); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index fba703b1540f9..1523a0d9a89f9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -6,19 +6,21 @@ import { isEqual } from 'lodash'; +import { Dictionary } from '../../../../../../../common/types/common'; +import { PivotSupportedAggs } from '../../../../../../../common/types/pivot_aggs'; +import { TransformPivotConfig } from '../../../../../../../common/types/transform'; + import { matchAllQuery, PivotAggsConfig, PivotAggsConfigDict, PivotGroupByConfig, PivotGroupByConfigDict, - TransformPivotConfig, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../../common'; -import { Dictionary } from '../../../../../../../common/types/common'; import { StepDefineExposedState } from './types'; -import { getAggConfigFromEsAgg, PivotSupportedAggs } from '../../../../../common/pivot_aggs'; +import { getAggConfigFromEsAgg } from '../../../../../common/pivot_aggs'; export function applyTransformConfigToDefineState( state: StepDefineExposedState, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 9d3ab44aa5708..d59f99192621c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { debounce } from 'lodash'; import { useUpdateEffect } from 'react-use'; import { i18n } from '@kbn/i18n'; +import { isEsSearchResponse } from '../../../../../../../../../common/api_schemas/type_guards'; import { useApi } from '../../../../../../../hooks'; import { CreateTransformWizardContext } from '../../../../wizard/wizard'; import { FilterAggConfigTerm } from '../types'; @@ -55,22 +56,24 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm }, }; - try { - const response = await api.esSearch(esSearchRequest); - setOptions( - response.aggregations.field_values.buckets.map( - (value: { key: string; doc_count: number }) => ({ label: value.key }) - ) - ); - } catch (e) { + const response = await api.esSearch(esSearchRequest); + + setIsLoading(false); + + if (!isEsSearchResponse(response)) { toastNotifications.addWarning( i18n.translate('xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions', { defaultMessage: 'Unable to fetch suggestions', }) ); + return; } - setIsLoading(false); + setOptions( + response.aggregations.field_values.buckets.map( + (value: { key: string; doc_count: number }) => ({ label: value.key }) + ) + ); }, 600), [selectedField] ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts index 2839c1181c333..5575e6d814daf 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts @@ -5,11 +5,11 @@ */ import { - PIVOT_SUPPORTED_AGGS, - PivotAggsConfigBase, - PivotAggsConfigWithUiBase, PivotSupportedAggs, -} from '../../../../../common/pivot_aggs'; + PIVOT_SUPPORTED_AGGS, +} from '../../../../../../../common/types/pivot_aggs'; + +import { PivotAggsConfigBase, PivotAggsConfigWithUiBase } from '../../../../../common/pivot_aggs'; import { getFilterAggConfig } from './filter_agg/config'; /** diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts index 57f9397089f1d..03cbf2e358736 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts @@ -6,7 +6,8 @@ import { i18n } from '@kbn/i18n'; -import { AggName, PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; +import { AggName } from '../../../../../../../common/types/aggregations'; +import { PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; export function getAggNameConflictToastMessages( aggName: AggName, @@ -36,7 +37,7 @@ export function getAggNameConflictToastMessages( // check the new aggName against existing aggs and groupbys const aggNameSplit = aggName.split('.'); let aggNameCheck: string; - aggNameSplit.forEach((aggNamePart) => { + aggNameSplit.forEach((aggNamePart: string) => { aggNameCheck = aggNameCheck === undefined ? aggNamePart : `${aggNameCheck}.${aggNamePart}`; if (aggList[aggNameCheck] !== undefined || groupByList[aggNameCheck] !== undefined) { conflicts.push( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 460164c9afe73..14c03aebe892a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EsFieldName } from '../../../../../../../common/types/fields'; import { - EsFieldName, - PERCENTILES_AGG_DEFAULT_PERCENTS, + PivotSupportedAggs, PIVOT_SUPPORTED_AGGS, +} from '../../../../../../../common/types/pivot_aggs'; +import { + PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAggsConfigWithUiSupport, } from '../../../../../common'; -import { PivotSupportedAggs } from '../../../../../common/pivot_aggs'; import { getFilterAggConfig } from './filter_agg/config'; /** diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts index 712a745ff6e77..657e8c935b875 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EsFieldName, - GroupByConfigWithUiSupport, - PIVOT_SUPPORTED_GROUP_BY_AGGS, -} from '../../../../../common'; +import { EsFieldName } from '../../../../../../../common/types/fields'; + +import { GroupByConfigWithUiSupport, PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../../../../common'; export function getDefaultGroupByConfig( aggName: string, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts index 56fde98cd4c71..955982aae6007 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts @@ -6,7 +6,9 @@ import { KBN_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { EsFieldName, PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; +import { EsFieldName } from '../../../../../../../common/types/fields'; + +import { PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; import { SavedSearchQuery } from '../../../../../hooks/use_search_items'; import { QUERY_LANGUAGE } from './constants'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts index 2e92114286599..41b84f04db852 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts @@ -8,13 +8,13 @@ import { useEffect, useState } from 'react'; import { useXJsonMode } from '../../../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; -import { PreviewRequestBody } from '../../../../../common'; +import { PostTransformsPreviewRequestSchema } from '../../../../../../../common/api_schemas/transforms'; import { StepDefineExposedState } from '../common'; export const useAdvancedPivotEditor = ( defaults: StepDefineExposedState, - previewRequest: PreviewRequestBody + previewRequest: PostTransformsPreviewRequestSchema ) => { const stringifiedPivotConfig = JSON.stringify(previewRequest.pivot, null, 2); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts index 1ea8a45248fb9..3f930711b970a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts @@ -6,13 +6,13 @@ import { useState } from 'react'; -import { PreviewRequestBody } from '../../../../../common'; +import { PostTransformsPreviewRequestSchema } from '../../../../../../../common/api_schemas/transforms'; import { StepDefineExposedState } from '../common'; export const useAdvancedSourceEditor = ( defaults: StepDefineExposedState, - previewRequest: PreviewRequestBody + previewRequest: PostTransformsPreviewRequestSchema ) => { const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index d35d567fc8469..90b28f0e305a5 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -6,11 +6,11 @@ import { useCallback, useMemo, useState } from 'react'; +import { AggName } from '../../../../../../../common/types/aggregations'; import { dictionaryToArray } from '../../../../../../../common/types/common'; import { useToastNotifications } from '../../../../../app_dependencies'; import { - AggName, DropDownLabel, PivotAggsConfig, PivotAggsConfigDict, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index f5980ae2243d3..7c10201fc3a6e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -6,7 +6,7 @@ import { useEffect } from 'react'; -import { getPreviewRequestBody } from '../../../../../common'; +import { getPreviewTransformRequestBody } from '../../../../../common'; import { getDefaultStepDefineState } from '../common'; @@ -26,7 +26,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi const searchBar = useSearchBar(defaults, indexPattern); const pivotConfig = usePivotConfig(defaults, indexPattern); - const previewRequest = getPreviewRequestBody( + const previewRequest = getPreviewTransformRequestBody( indexPattern.title, searchBar.state.pivotQuery, pivotConfig.state.pivotGroupByArr, @@ -41,7 +41,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi useEffect(() => { if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { - const previewRequestUpdate = getPreviewRequestBody( + const previewRequestUpdate = getPreviewTransformRequestBody( indexPattern.title, searchBar.state.pivotQuery, pivotConfig.state.pivotGroupByArr, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 8c919a5185d7e..986ac0a212e8a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -15,10 +15,11 @@ import { coreMock } from '../../../../../../../../../src/core/public/mocks'; import { dataPluginMock } from '../../../../../../../../../src/plugins/data/public/mocks'; const startMock = coreMock.createStart(); +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + import { PivotAggsConfigDict, PivotGroupByConfigDict, - PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index e0b350542a8f8..10f473074b4d7 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -22,6 +22,9 @@ import { EuiText, } from '@elastic/eui'; +import { PivotAggDict } from '../../../../../../common/types/pivot_aggs'; +import { PivotGroupByDict } from '../../../../../../common/types/pivot_group_by'; + import { DataGrid } from '../../../../../shared_imports'; import { @@ -30,10 +33,8 @@ import { } from '../../../../common/data_grid'; import { - getPreviewRequestBody, - PivotAggDict, + getPreviewTransformRequestBody, PivotAggsConfigDict, - PivotGroupByDict, PivotGroupByConfigDict, PivotSupportedGroupByAggs, PivotAggsConfig, @@ -87,7 +88,7 @@ export const StepDefineForm: FC = React.memo((props) => { toastNotifications, }; - const previewRequest = getPreviewRequestBody( + const previewRequest = getPreviewTransformRequestBody( indexPattern.title, pivotQuery, pivotGroupByArr, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index dc3d950938c9e..f8a060e0007b8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -7,10 +7,11 @@ import React from 'react'; import { render, wait } from '@testing-library/react'; +import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; + import { PivotAggsConfig, PivotGroupByConfig, - PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 414f6e37504da..fa4f8a7e09690 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -18,7 +18,7 @@ import { useToastNotifications } from '../../../../app_dependencies'; import { getPivotQuery, getPivotPreviewDevConsoleStatement, - getPreviewRequestBody, + getPreviewTransformRequestBody, isDefaultQuery, isMatchAllQuery, } from '../../../../common'; @@ -44,7 +44,7 @@ export const StepDefineSummary: FC = ({ const pivotGroupByArr = dictionaryToArray(groupByList); const pivotQuery = getPivotQuery(searchQuery); - const previewRequest = getPreviewRequestBody( + const previewRequest = getPreviewTransformRequestBody( searchItems.indexPattern.title, pivotQuery, pivotGroupByArr, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 85f4065e8c069..43d4f11cffc9d 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -11,24 +11,28 @@ import { i18n } from '@kbn/i18n'; import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common'; - import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { TransformId } from '../../../../../../common'; + +import { + isEsIndices, + isPostTransformsPreviewResponseSchema, +} from '../../../../../../common/api_schemas/type_guards'; +import { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; import { getErrorMessage } from '../../../../../../common/utils/errors'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { ToastNotificationText } from '../../../../components'; +import { isHttpFetchError } from '../../../../common/request'; import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; import { SearchItems } from '../../../../hooks/use_search_items'; import { useApi } from '../../../../hooks/use_api'; import { StepDetailsTimeField } from './step_details_time_field'; import { getPivotQuery, - getPreviewRequestBody, + getPreviewTransformRequestBody, isTransformIdValid, - TransformPivotConfig, } from '../../../../common'; import { EsIndexName, IndexPatternTitle } from './common'; import { delayValidator } from '../../../../common/validators'; @@ -48,10 +52,12 @@ export interface StepDetailsExposedState { indexPatternDateField?: string | undefined; } +const defaultContinuousModeDelay = '60s'; + export function getDefaultStepDetailsState(): StepDetailsExposedState { return { continuousModeDateField: '', - continuousModeDelay: '60s', + continuousModeDelay: defaultContinuousModeDelay, createIndexPattern: true, isContinuousModeEnabled: false, transformId: '', @@ -72,7 +78,7 @@ export function applyTransformConfigToDetailsState( const time = transformConfig.sync?.time; if (time !== undefined) { state.continuousModeDateField = time.field; - state.continuousModeDelay = time.delay; + state.continuousModeDelay = time?.delay ?? defaultContinuousModeDelay; state.isContinuousModeEnabled = true; } } @@ -137,19 +143,20 @@ export const StepDetailsForm: FC = React.memo( useEffect(() => { // use an IIFE to avoid returning a Promise to useEffect. (async function () { - try { - const { searchQuery, groupByList, aggList } = stepDefineState; - const pivotAggsArr = dictionaryToArray(aggList); - const pivotGroupByArr = dictionaryToArray(groupByList); - const pivotQuery = getPivotQuery(searchQuery); - const previewRequest = getPreviewRequestBody( - searchItems.indexPattern.title, - pivotQuery, - pivotGroupByArr, - pivotAggsArr - ); - - const transformPreview = await api.getTransformsPreview(previewRequest); + const { searchQuery, groupByList, aggList } = stepDefineState; + const pivotAggsArr = dictionaryToArray(aggList); + const pivotGroupByArr = dictionaryToArray(groupByList); + const pivotQuery = getPivotQuery(searchQuery); + const previewRequest = getPreviewTransformRequestBody( + searchItems.indexPattern.title, + pivotQuery, + pivotGroupByArr, + pivotAggsArr + ); + + const transformPreview = await api.getTransformsPreview(previewRequest); + + if (isPostTransformsPreviewResponseSchema(transformPreview)) { const properties = transformPreview.generated_dest_index.mappings.properties; const datetimeColumns: string[] = Object.keys(properties).filter( (col) => properties[col].type === 'date' @@ -157,43 +164,46 @@ export const StepDetailsForm: FC = React.memo( setPreviewDateColumns(datetimeColumns); setIndexPatternDateField(datetimeColumns[0]); - } catch (e) { + } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { - defaultMessage: 'An error occurred getting transform preview', + defaultMessage: 'An error occurred fetching the transform preview', }), text: toMountPoint( - + ), }); } - try { - setTransformIds( - (await api.getTransforms()).transforms.map( - (transform: TransformPivotConfig) => transform.id - ) - ); - } catch (e) { + const resp = await api.getTransforms(); + + if (isHttpFetchError(resp)) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', { defaultMessage: 'An error occurred getting the existing transform IDs:', }), text: toMountPoint( - + ), }); + } else { + setTransformIds(resp.transforms.map((transform: TransformPivotConfig) => transform.id)); } - try { - setIndexNames((await api.getIndices()).map((index) => index.name)); - } catch (e) { + const indices = await api.getEsIndices(); + + if (isEsIndices(indices)) { + setIndexNames(indices.map((index) => index.name)); + } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { defaultMessage: 'An error occurred getting the existing index names:', }), text: toMountPoint( - + ), }); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 806dcbfa75604..0ca018972cac9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -10,7 +10,9 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { getCreateRequestBody, TransformPivotConfig } from '../../../../common'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; + +import { getCreateTransformRequestBody } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; import { @@ -149,7 +151,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) } }, []); - const transformConfig = getCreateRequestBody( + const transformConfig = getCreateTransformRequestBody( indexPattern.title, stepDefineState, stepDetailsState diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx index d8ab72f15c59c..75868fb8fcabd 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TransformState, TRANSFORM_STATE } from '../../../../../../common/constants'; import { createCapabilityFailureMessage } from '../../../../lib/authorization'; import { TransformListRow } from '../../../../common'; @@ -18,8 +18,8 @@ export const deleteActionNameText = i18n.translate( } ); -const transformCanNotBeDeleted = (item: TransformListRow) => - ![TRANSFORM_STATE.STOPPED, TRANSFORM_STATE.FAILED].includes(item.stats.state); +const transformCanNotBeDeleted = (i: TransformListRow) => + !([TRANSFORM_STATE.STOPPED, TRANSFORM_STATE.FAILED] as TransformState[]).includes(i.stats.state); export const isDeleteActionDisabled = (items: TransformListRow[], forceDisable: boolean) => { const disabled = items.some(transformCanNotBeDeleted); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index e573709fa6e63..7e8e099b69f82 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -6,7 +6,7 @@ import React, { useContext, useMemo, useState } from 'react'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { TransformListAction, TransformListRow } from '../../../../common'; import { useDeleteIndexAndTargetIndex, useDeleteTransforms } from '../../../../hooks'; @@ -55,7 +55,16 @@ export const useDeleteAction = (forceDisable: boolean) => { const forceDelete = isBulkAction ? shouldForceDelete : items[0] && items[0] && items[0].stats.state === TRANSFORM_STATE.FAILED; - deleteTransforms(items, shouldDeleteDestIndex, shouldDeleteDestIndexPattern, forceDelete); + + deleteTransforms({ + transformsInfo: items.map((i) => ({ + id: i.config.id, + state: i.stats.state, + })), + deleteDestIndex: shouldDeleteDestIndex, + deleteDestIndexPattern: shouldDeleteDestIndexPattern, + forceDelete, + }); }; const openModal = (newItems: TransformListRow[]) => { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index 1fe20f1acae5a..192ff7ac74c57 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -6,7 +6,9 @@ import React, { useContext, useMemo, useState } from 'react'; -import { TransformListAction, TransformListRow, TransformPivotConfig } from '../../../../common'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; + +import { TransformListAction, TransformListRow } from '../../../../common'; import { AuthorizationContext } from '../../../../lib/authorization'; import { editActionNameText, EditActionName } from './edit_action_name'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx index 191df0c16cba0..ca1c90b9b8fae 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx @@ -8,7 +8,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { createCapabilityFailureMessage, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx index 8d6a4376c55b3..96af60778d6a4 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx @@ -6,7 +6,7 @@ import React, { useContext, useMemo, useState } from 'react'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { AuthorizationContext } from '../../../../lib/authorization'; import { TransformListAction, TransformListRow } from '../../../../common'; @@ -27,7 +27,7 @@ export const useStartAction = (forceDisable: boolean) => { const startAndCloseModal = () => { setModalVisible(false); - startTransforms(items); + startTransforms(items.map((i) => ({ id: i.id }))); }; const openModal = (newItems: TransformListRow[]) => { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx index e1ea82cb371e8..4ec30faa4d76b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx @@ -8,7 +8,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { TransformListRow } from '../../../../common'; import { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx index e0a7e0b489ab6..4c872114a82ab 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { AuthorizationContext } from '../../../../lib/authorization'; import { TransformListAction, TransformListRow } from '../../../../common'; @@ -20,9 +20,10 @@ export const useStopAction = (forceDisable: boolean) => { const stopTransforms = useStopTransforms(); - const clickHandler = useCallback((item: TransformListRow) => stopTransforms([item]), [ - stopTransforms, - ]); + const clickHandler = useCallback( + (i: TransformListRow) => stopTransforms([{ id: i.id, state: i.stats.state }]), + [stopTransforms] + ); const action: TransformListAction = useMemo( () => ({ diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index 735a059e57e14..f9cdac51b6582 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -23,13 +23,12 @@ import { EuiTitle, } from '@elastic/eui'; +import { isPostTransformsUpdateResponseSchema } from '../../../../../../common/api_schemas/type_guards'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; + import { getErrorMessage } from '../../../../../../common/utils/errors'; -import { - refreshTransformList$, - TransformPivotConfig, - REFRESH_TRANSFORM_LIST_STATE, -} from '../../../../common'; +import { refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../../../../common'; import { useToastNotifications } from '../../../../app_dependencies'; import { useApi } from '../../../../hooks/use_api'; @@ -58,19 +57,21 @@ export const EditTransformFlyout: FC = ({ closeFlyout, const requestConfig = applyFormFieldsToTransformConfig(config, state.formFields); const transformId = config.id; - try { - await api.updateTransform(transformId, requestConfig); - toastNotifications.addSuccess( - i18n.translate('xpack.transform.transformList.editTransformSuccessMessage', { - defaultMessage: 'Transform {transformId} updated.', - values: { transformId }, - }) - ); - closeFlyout(); - refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); - } catch (e) { - setErrorMessage(getErrorMessage(e)); + const resp = await api.updateTransform(transformId, requestConfig); + + if (!isPostTransformsUpdateResponseSchema(resp)) { + setErrorMessage(getErrorMessage(resp)); + return; } + + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.editTransformSuccessMessage', { + defaultMessage: 'Transform {transformId} updated.', + values: { transformId }, + }) + ); + closeFlyout(); + refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); } const isUpdateButtonDisabled = !state.isFormValid || !state.isFormTouched; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts index 4a8b26b601ae2..12e60c2af5556 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TransformPivotConfig } from '../../../../common'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; import { applyFormFieldsToTransformConfig, @@ -86,9 +86,7 @@ describe('Transform: applyFormFieldsToTransformConfig()', () => { }); test('should include previously nonexisting attributes', () => { - const transformConfigMock = getTransformConfigMock(); - delete transformConfigMock.description; - delete transformConfigMock.frequency; + const { description, frequency, ...transformConfigMock } = getTransformConfigMock(); const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, { description: getDescriptionFieldMock('the-new-description'), diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts index 649db51e6ea78..d622a7e9cc040 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts @@ -9,7 +9,8 @@ import { useReducer } from 'react'; import { i18n } from '@kbn/i18n'; -import { TransformPivotConfig } from '../../../../common'; +import { PostTransformsUpdateRequestSchema } from '../../../../../../common/api_schemas/update_transforms'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; // A Validator function takes in a value to check and returns an array of error messages. // If no messages (empty array) get returned, the value is valid. @@ -118,53 +119,35 @@ interface Action { value: string; } -// Some attributes can have a value of `null` to trigger -// a reset to the default value, or in the case of `docs_per_second` -// `null` is used to disable throttling. -interface UpdateTransformPivotConfig { - description: string; - frequency: string; - settings: { - docs_per_second: number | null; - }; -} - // Takes in the form configuration and returns a // request object suitable to be sent to the // transform update API endpoint. export const applyFormFieldsToTransformConfig = ( config: TransformPivotConfig, { description, docsPerSecond, frequency }: EditTransformFlyoutFieldsState -): Partial => { - const updateConfig: Partial = {}; - - // set the values only if they changed from the default - // and actually differ from the previous value. - if ( - !(config.frequency === undefined && frequency.value === '') && - config.frequency !== frequency.value - ) { - updateConfig.frequency = frequency.value; - } - - if ( - !(config.description === undefined && description.value === '') && - config.description !== description.value - ) { - updateConfig.description = description.value; - } - +): PostTransformsUpdateRequestSchema => { // if the input field was left empty, // fall back to the default value of `null` // which will disable throttling. const docsPerSecondFormValue = docsPerSecond.value !== '' ? parseInt(docsPerSecond.value, 10) : null; const docsPerSecondConfigValue = config.settings?.docs_per_second ?? null; - if (docsPerSecondFormValue !== docsPerSecondConfigValue) { - updateConfig.settings = { docs_per_second: docsPerSecondFormValue }; - } - return updateConfig; + return { + // set the values only if they changed from the default + // and actually differ from the previous value. + ...(!(config.frequency === undefined && frequency.value === '') && + config.frequency !== frequency.value + ? { frequency: frequency.value } + : {}), + ...(!(config.description === undefined && description.value === '') && + config.description !== description.value + ? { description: description.value } + : {}), + ...(docsPerSecondFormValue !== docsPerSecondConfigValue + ? { settings: { docs_per_second: docsPerSecondFormValue } } + : {}), + }; }; // Takes in a transform configuration and returns diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts index 11e4dc3dfa2b8..f6708f7c36f26 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; import mockTransformListRow from '../../../../common/__mocks__/transform_list_row.json'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx index 08545c288ba96..02bad50dc0dfd 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx @@ -9,11 +9,15 @@ import React, { useState } from 'react'; import { EuiSpacer, EuiBasicTable } from '@elastic/eui'; // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; -import { i18n } from '@kbn/i18n'; import theme from '@elastic/eui/dist/eui_theme_light.json'; + +import { i18n } from '@kbn/i18n'; + +import { isGetTransformsAuditMessagesResponseSchema } from '../../../../../../common/api_schemas/type_guards'; +import { TransformMessage } from '../../../../../../common/types/messages'; + import { useApi } from '../../../../hooks/use_api'; import { JobIcon } from '../../../../components/job_icon'; -import { TransformMessage } from '../../../../../../common/types/messages'; import { useRefreshTransformList } from '../../../../common'; const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; @@ -36,25 +40,16 @@ export const ExpandedRowMessagesPane: React.FC = ({ transformId }) => { let concurrentLoads = 0; return async function getMessages() { - try { - concurrentLoads++; - - if (concurrentLoads > 1) { - return; - } + concurrentLoads++; - setIsLoading(true); - const messagesResp = await api.getTransformAuditMessages(transformId); - setIsLoading(false); - setMessages(messagesResp as any[]); + if (concurrentLoads > 1) { + return; + } - concurrentLoads--; + setIsLoading(true); + const messagesResp = await api.getTransformAuditMessages(transformId); - if (concurrentLoads > 0) { - concurrentLoads = 0; - getMessages(); - } - } catch (error) { + if (!isGetTransformsAuditMessagesResponseSchema(messagesResp)) { setIsLoading(false); setErrorMessage( i18n.translate( @@ -64,6 +59,17 @@ export const ExpandedRowMessagesPane: React.FC = ({ transformId }) => { } ) ); + return; + } + + setIsLoading(false); + setMessages(messagesResp as any[]); + + concurrentLoads--; + + if (concurrentLoads > 0) { + concurrentLoads = 0; + getMessages(); } }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx index a917fc73ad8fb..87d9a25dababd 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx @@ -6,10 +6,11 @@ import React, { useMemo, FC } from 'react'; +import { TransformPivotConfig } from '../../../../../../common/types/transform'; import { DataGrid } from '../../../../../shared_imports'; import { useToastNotifications } from '../../../../app_dependencies'; -import { getPivotQuery, TransformPivotConfig } from '../../../../common'; +import { getPivotQuery } from '../../../../common'; import { usePivotData } from '../../../../hooks/use_pivot_data'; import { SearchItems } from '../../../../hooks/use_search_items'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index dad0f0e5ee282..12836c0a18ce2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -23,7 +23,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { TransformId } from '../../../../../../common'; +import { TransformId } from '../../../../../../common/types/transform'; import { useRefreshTransformList, @@ -189,7 +189,11 @@ export const TransformList: FC = ({
,
- stopTransforms(transformSelection)}> + + stopTransforms(transformSelection.map((t) => ({ id: t.id, state: t.stats.state }))) + } + >
, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx index fab591f881310..fdcb9ba5f0aff 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar.tsx @@ -16,8 +16,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TermClause, FieldClause, Value } from './common'; -import { TRANSFORM_STATE } from '../../../../../../common'; -import { TRANSFORM_MODE, TransformListRow } from '../../../../common'; +import { TRANSFORM_MODE, TRANSFORM_STATE } from '../../../../../../common/constants'; +import { TransformListRow } from '../../../../common'; import { getTaskStateBadge } from './use_columns'; const filters: SearchFilterConfig[] = [ diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx index bce01b954c83e..313668d4c5180 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx @@ -7,9 +7,9 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { TRANSFORM_STATE } from '../../../../../../common'; +import { TRANSFORM_MODE, TRANSFORM_STATE } from '../../../../../../common/constants'; -import { TRANSFORM_MODE, TransformListRow } from '../../../../common'; +import { TransformListRow } from '../../../../common'; import { StatsBar, TransformStatsBarStats } from '../stats_bar'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index d2d8c7084941d..040e502ce4888 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -22,24 +22,21 @@ import { RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; +import { TransformId } from '../../../../../../common/types/transform'; +import { TransformStats } from '../../../../../../common/types/transform_stats'; +import { TRANSFORM_STATE } from '../../../../../../common/constants'; -import { - getTransformProgress, - TransformListRow, - TransformStats, - TRANSFORM_LIST_COLUMN, -} from '../../../../common'; +import { getTransformProgress, TransformListRow, TRANSFORM_LIST_COLUMN } from '../../../../common'; import { useActions } from './use_actions'; -enum STATE_COLOR { - aborting = 'warning', - failed = 'danger', - indexing = 'primary', - started = 'primary', - stopped = 'hollow', - stopping = 'hollow', -} +const STATE_COLOR = { + aborting: 'warning', + failed: 'danger', + indexing: 'primary', + started: 'primary', + stopped: 'hollow', + stopping: 'hollow', +} as const; export const getTaskStateBadge = ( state: TransformStats['state'], diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index 196df250b7a3d..4737787dbd9ee 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -27,7 +27,6 @@ export { DataGrid, EsSorting, RenderCellValue, - SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, INDEX_STATUS, diff --git a/x-pack/plugins/transform/server/README.md b/x-pack/plugins/transform/server/README.md new file mode 100644 index 0000000000000..1142c1fea094d --- /dev/null +++ b/x-pack/plugins/transform/server/README.md @@ -0,0 +1,19 @@ +# Transform Kibana API routes + +This folder contains Transform API routes in Kibana. + +Each route handler requires [apiDoc](https://github.com/apidoc/apidoc) annotations in order +to generate documentation. +The [apidoc-markdown](https://github.com/rigwild/apidoc-markdown) package is also required in order to generate the markdown. + +There are custom parser and worker (`x-pack/plugins/transform/server/routes/apidoc_scripts`) to process api schemas for each documentation entry. It's written with typescript so make sure all the scripts in the folder are compiled before executing `apidoc` command. + +Make sure you have run `yarn kbn bootstrap` to get all requires dev dependencies. Then execute the following command from the transform plugin folder: +``` +yarn run apiDocs +``` +It compiles all the required scripts and generates the documentation both in HTML and Markdown formats. + + +It will create a new directory `routes_doc` (next to the `routes` folder) which contains the documentation in HTML format +as well as `Transform_API.md` file. diff --git a/x-pack/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts index 5a479e4f429f6..269cd28c4bda6 100644 --- a/x-pack/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -10,11 +10,8 @@ import { i18n } from '@kbn/i18n'; import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; -import { - TransformEndpointRequest, - TransformEndpointResult, - DeleteTransformEndpointResult, -} from '../../../common'; +import { CommonResponseStatusSchema, TransformIdsSchema } from '../../../common/api_schemas/common'; +import { DeleteTransformsResponseSchema } from '../../../common/api_schemas/delete_transforms'; const REQUEST_TIMEOUT = 'RequestTimeout'; @@ -23,9 +20,9 @@ export function isRequestTimeout(error: any) { } interface Params { - results: TransformEndpointResult | DeleteTransformEndpointResult; + results: CommonResponseStatusSchema | DeleteTransformsResponseSchema; id: string; - items: TransformEndpointRequest[]; + items: TransformIdsSchema; action: string; } @@ -63,7 +60,7 @@ export function fillResultsWithTimeouts({ results, id, items, action }: Params) }, }; - const newResults: TransformEndpointResult | DeleteTransformEndpointResult = {}; + const newResults: CommonResponseStatusSchema | DeleteTransformsResponseSchema = {}; return items.reduce((accumResults, currentVal) => { if (results[currentVal.id] === undefined) { diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index 2642040c4cd0d..88352ec4af129 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -11,40 +11,49 @@ import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; +import { + indexPatternTitleSchema, + IndexPatternTitleSchema, +} from '../../../common/api_schemas/common'; +import { + fieldHistogramsRequestSchema, + FieldHistogramsRequestSchema, +} from '../../../common/api_schemas/field_histograms'; import { getHistogramsForFields } from '../../shared_imports'; import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; import { wrapError } from './error_utils'; -import { fieldHistogramsSchema, indexPatternTitleSchema, IndexPatternTitleSchema } from './schema'; export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { - router.post( + router.post( { path: addBasePath('field_histograms/{indexPatternTitle}'), validate: { params: indexPatternTitleSchema, - body: fieldHistogramsSchema, + body: fieldHistogramsRequestSchema, }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { indexPatternTitle } = req.params as IndexPatternTitleSchema; - const { query, fields, samplerShardSize } = req.body; + license.guardApiRoute( + async (ctx, req, res) => { + const { indexPatternTitle } = req.params; + const { query, fields, samplerShardSize } = req.body; - try { - const resp = await getHistogramsForFields( - ctx.core.elasticsearch.client, - indexPatternTitle, - query, - fields, - samplerShardSize - ); + try { + const resp = await getHistogramsForFields( + ctx.core.elasticsearch.client, + indexPatternTitle, + query, + fields, + samplerShardSize + ); - return res.ok({ body: resp }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); + return res.ok({ body: resp }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } } - }) + ) ); } diff --git a/x-pack/plugins/transform/server/routes/api/privileges.ts b/x-pack/plugins/transform/server/routes/api/privileges.ts index 2b7b0544a8bf9..605cbde356fdf 100644 --- a/x-pack/plugins/transform/server/routes/api/privileges.ts +++ b/x-pack/plugins/transform/server/routes/api/privileges.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { APP_CLUSTER_PRIVILEGES, APP_INDEX_PRIVILEGES } from '../../../common/constants'; -import { Privileges } from '../../../common'; +import { Privileges } from '../../../common/types/privileges'; import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts deleted file mode 100644 index 8aadef81b221b..0000000000000 --- a/x-pack/plugins/transform/server/routes/api/schema.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { schema } from '@kbn/config-schema'; - -export const fieldHistogramsSchema = schema.object({ - /** Query to match documents in the index. */ - query: schema.any(), - /** The fields to return histogram data. */ - fields: schema.arrayOf(schema.any()), - /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ - samplerShardSize: schema.number(), -}); - -export const indexPatternTitleSchema = schema.object({ - /** Title of the index pattern for which to return stats. */ - indexPatternTitle: schema.string(), -}); - -export interface IndexPatternTitleSchema { - indexPatternTitle: string; -} - -export const schemaTransformId = { - params: schema.object({ - transformId: schema.string(), - }), -}; - -export interface SchemaTransformId { - transformId: string; -} - -export const deleteTransformSchema = schema.object({ - /** - * Delete Transform & Destination Index - */ - transformsInfo: schema.arrayOf( - schema.object({ - id: schema.string(), - state: schema.maybe(schema.string()), - }) - ), - deleteDestIndex: schema.maybe(schema.boolean()), - deleteDestIndexPattern: schema.maybe(schema.boolean()), - forceDelete: schema.maybe(schema.boolean()), -}); diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index efbe813db5e67..c02bc06ad6060 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -14,22 +14,47 @@ import { import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; +import { TRANSFORM_STATE } from '../../../common/constants'; +import { TransformId } from '../../../common/types/transform'; import { - TransformEndpointRequest, - TransformEndpointResult, - TransformId, - TRANSFORM_STATE, - DeleteTransformEndpointRequest, - DeleteTransformStatus, - ResultData, -} from '../../../common'; + transformIdParamSchema, + ResponseStatus, + TransformIdParamSchema, +} from '../../../common/api_schemas/common'; +import { + deleteTransformsRequestSchema, + DeleteTransformsRequestSchema, + DeleteTransformsResponseSchema, +} from '../../../common/api_schemas/delete_transforms'; +import { + startTransformsRequestSchema, + StartTransformsRequestSchema, + StartTransformsResponseSchema, +} from '../../../common/api_schemas/start_transforms'; +import { + stopTransformsRequestSchema, + StopTransformsRequestSchema, + StopTransformsResponseSchema, +} from '../../../common/api_schemas/stop_transforms'; +import { + postTransformsUpdateRequestSchema, + PostTransformsUpdateRequestSchema, + PostTransformsUpdateResponseSchema, +} from '../../../common/api_schemas/update_transforms'; +import { + GetTransformsResponseSchema, + postTransformsPreviewRequestSchema, + PostTransformsPreviewRequestSchema, + putTransformsRequestSchema, + PutTransformsRequestSchema, + PutTransformsResponseSchema, +} from '../../../common/api_schemas/transforms'; import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError } from './error_utils'; -import { deleteTransformSchema, schemaTransformId, SchemaTransformId } from './schema'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; import { IIndexPattern } from '../../../../../../src/plugins/data/common/index_patterns'; @@ -47,6 +72,16 @@ interface StopOptions { export function registerTransformsRoutes(routeDependencies: RouteDependencies) { const { router, license } = routeDependencies; + /** + * @apiGroup Transforms + * + * @api {get} /api/transform/transforms Get transforms + * @apiName GetTransforms + * @apiDescription Returns transforms + * + * @apiSchema (params) jobAuditMessagesJobIdSchema + * @apiSchema (query) jobAuditMessagesQuerySchema + */ router.get( { path: addBasePath('transforms'), validate: false }, license.guardApiRoute(async (ctx, req, res) => { @@ -62,16 +97,24 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { } }) ); - router.get( + + /** + * @apiGroup Transforms + * + * @api {get} /api/transform/transforms/:transformId Get transform + * @apiName GetTransform + * @apiDescription Returns a single transform + * + * @apiSchema (params) transformIdParamSchema + */ + router.get( { path: addBasePath('transforms/{transformId}'), - validate: schemaTransformId, + validate: { params: transformIdParamSchema }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { transformId } = req.params as SchemaTransformId; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params; + const options = transformId !== undefined ? { transformId } : {}; try { const transforms = await getTransforms( options, @@ -83,6 +126,14 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { } }) ); + + /** + * @apiGroup Transforms + * + * @api {get} /api/transform/transforms/_stats Get transforms stats + * @apiName GetTransformsStats + * @apiDescription Returns transforms stats + */ router.get( { path: addBasePath('transforms/_stats'), validate: false }, license.guardApiRoute(async (ctx, req, res) => { @@ -98,13 +149,23 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { } }) ); - router.get( + + /** + * @apiGroup Transforms + * + * @api {get} /api/transform/transforms/:transformId/_stats Get transform stats + * @apiName GetTransformStats + * @apiDescription Returns stats for a single transform + * + * @apiSchema (params) transformIdParamSchema + */ + router.get( { path: addBasePath('transforms/{transformId}/_stats'), - validate: schemaTransformId, + validate: { params: transformIdParamSchema }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { transformId } = req.params as SchemaTransformId; + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params; const options = { ...(transformId !== undefined ? { transformId } : {}), }; @@ -120,134 +181,198 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { }) ); registerTransformsAuditMessagesRoutes(routeDependencies); - router.put( + + /** + * @apiGroup Transforms + * + * @api {put} /api/transform/transforms/:transformId Put transform + * @apiName PutTransform + * @apiDescription Creates a transform + * + * @apiSchema (params) transformIdParamSchema + * @apiSchema (body) putTransformsRequestSchema + */ + router.put( { path: addBasePath('transforms/{transformId}'), validate: { - ...schemaTransformId, - body: schema.maybe(schema.any()), + params: transformIdParamSchema, + body: putTransformsRequestSchema, }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { transformId } = req.params as SchemaTransformId; - - const response: { - transformsCreated: Array<{ transform: string }>; - errors: any[]; - } = { - transformsCreated: [], - errors: [], - }; + license.guardApiRoute( + async (ctx, req, res) => { + const { transformId } = req.params; - await ctx - .transform!.dataClient.callAsCurrentUser('transform.createTransform', { - body: req.body, - transformId, - }) - .then(() => response.transformsCreated.push({ transform: transformId })) - .catch((e) => - response.errors.push({ - id: transformId, - error: wrapEsError(e), + const response: PutTransformsResponseSchema = { + transformsCreated: [], + errors: [], + }; + + await ctx + .transform!.dataClient.callAsCurrentUser('transform.createTransform', { + body: req.body, + transformId, }) - ); + .then(() => response.transformsCreated.push({ transform: transformId })) + .catch((e) => + response.errors.push({ + id: transformId, + error: wrapEsError(e), + }) + ); - return res.ok({ body: response }); - }) + return res.ok({ body: response }); + } + ) ); - router.post( + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/transforms/:transformId/_update Post transform update + * @apiName PostTransformUpdate + * @apiDescription Updates a transform + * + * @apiSchema (params) transformIdParamSchema + * @apiSchema (body) postTransformsUpdateRequestSchema + */ + router.post( { path: addBasePath('transforms/{transformId}/_update'), validate: { - ...schemaTransformId, - body: schema.maybe(schema.any()), + params: transformIdParamSchema, + body: postTransformsUpdateRequestSchema, }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { transformId } = req.params as SchemaTransformId; + license.guardApiRoute( + async (ctx, req, res) => { + const { transformId } = req.params; - try { - return res.ok({ - body: await ctx.transform!.dataClient.callAsCurrentUser('transform.updateTransform', { - body: req.body, - transformId, - }), - }); - } catch (e) { - return res.customError(wrapError(e)); + try { + return res.ok({ + body: (await ctx.transform!.dataClient.callAsCurrentUser('transform.updateTransform', { + body: req.body, + transformId, + })) as PostTransformsUpdateResponseSchema, + }); + } catch (e) { + return res.customError(wrapError(e)); + } } - }) + ) ); - router.post( + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/delete_transforms Post delete transforms + * @apiName DeleteTransforms + * @apiDescription Deletes transforms + * + * @apiSchema (body) deleteTransformsRequestSchema + */ + router.post( { path: addBasePath('delete_transforms'), validate: { - body: deleteTransformSchema, + body: deleteTransformsRequestSchema, }, }, - license.guardApiRoute(async (ctx, req, res) => { - const { - transformsInfo, - deleteDestIndex, - deleteDestIndexPattern, - forceDelete, - } = req.body as DeleteTransformEndpointRequest; - - try { - const body = await deleteTransforms( - transformsInfo, - deleteDestIndex, - deleteDestIndexPattern, - forceDelete, - ctx, - license, - res - ); - - if (body && body.status) { - if (body.status === 404) { - return res.notFound(); - } - if (body.status === 403) { - return res.forbidden(); + license.guardApiRoute( + async (ctx, req, res) => { + try { + const body = await deleteTransforms(req.body, ctx, res); + + if (body && body.status) { + if (body.status === 404) { + return res.notFound(); + } + if (body.status === 403) { + return res.forbidden(); + } } - } - return res.ok({ - body, - }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); + return res.ok({ + body, + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } } - }) + ) ); - router.post( + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/transforms/_preview Preview transform + * @apiName PreviewTransform + * @apiDescription Previews transform + * + * @apiSchema (body) postTransformsPreviewRequestSchema + */ + router.post( { path: addBasePath('transforms/_preview'), validate: { - body: schema.maybe(schema.any()), + body: postTransformsPreviewRequestSchema, }, }, - license.guardApiRoute(previewTransformHandler) + license.guardApiRoute( + previewTransformHandler + ) ); - router.post( + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/start_transforms Start transforms + * @apiName PostStartTransforms + * @apiDescription Starts transform + * + * @apiSchema (body) startTransformsRequestSchema + */ + router.post( { path: addBasePath('start_transforms'), validate: { - body: schema.maybe(schema.any()), + body: startTransformsRequestSchema, }, }, - license.guardApiRoute(startTransformsHandler) + license.guardApiRoute( + startTransformsHandler + ) ); - router.post( + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/stop_transforms Stop transforms + * @apiName PostStopTransforms + * @apiDescription Stops transform + * + * @apiSchema (body) stopTransformsRequestSchema + */ + router.post( { path: addBasePath('stop_transforms'), validate: { - body: schema.maybe(schema.any()), + body: stopTransformsRequestSchema, }, }, - license.guardApiRoute(stopTransformsHandler) + license.guardApiRoute(stopTransformsHandler) ); + + /** + * @apiGroup Transforms + * + * @api {post} /api/transform/es_search Transform ES Search Proxy + * @apiName PostTransformEsSearchProxy + * @apiDescription ES Search Proxy + * + * @apiSchema (body) any + */ router.post( { path: addBasePath('es_search'), @@ -267,7 +392,10 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { ); } -const getTransforms = async (options: { transformId?: string }, callAsCurrentUser: CallCluster) => { +const getTransforms = async ( + options: { transformId?: string }, + callAsCurrentUser: CallCluster +): Promise => { return await callAsCurrentUser('transform.getTransforms', options); }; @@ -294,22 +422,25 @@ async function deleteDestIndexPatternById( } async function deleteTransforms( - transformsInfo: TransformEndpointRequest[], - deleteDestIndex: boolean | undefined, - deleteDestIndexPattern: boolean | undefined, - shouldForceDelete: boolean = false, + reqBody: DeleteTransformsRequestSchema, ctx: RequestHandlerContext, - license: RouteDependencies['license'], response: KibanaResponseFactory ) { - const results: Record = {}; + const { transformsInfo } = reqBody; + + // Cast possible undefineds as booleans + const deleteDestIndex = !!reqBody.deleteDestIndex; + const deleteDestIndexPattern = !!reqBody.deleteDestIndexPattern; + const shouldForceDelete = !!reqBody.forceDelete; + + const results: DeleteTransformsResponseSchema = {}; for (const transformInfo of transformsInfo) { let destinationIndex: string | undefined; - const transformDeleted: ResultData = { success: false }; - const destIndexDeleted: ResultData = { success: false }; - const destIndexPatternDeleted: ResultData = { + const transformDeleted: ResponseStatus = { success: false }; + const destIndexDeleted: ResponseStatus = { success: false }; + const destIndexPatternDeleted: ResponseStatus = { success: false, }; const transformId = transformInfo.id; @@ -405,7 +536,11 @@ async function deleteTransforms( return results; } -const previewTransformHandler: RequestHandler = async (ctx, req, res) => { +const previewTransformHandler: RequestHandler< + undefined, + undefined, + PostTransformsPreviewRequestSchema +> = async (ctx, req, res) => { try { return res.ok({ body: await ctx.transform!.dataClient.callAsCurrentUser('transform.getTransformsPreview', { @@ -417,8 +552,12 @@ const previewTransformHandler: RequestHandler = async (ctx, req, res) => { } }; -const startTransformsHandler: RequestHandler = async (ctx, req, res) => { - const transformsInfo = req.body as TransformEndpointRequest[]; +const startTransformsHandler: RequestHandler< + undefined, + undefined, + StartTransformsRequestSchema +> = async (ctx, req, res) => { + const transformsInfo = req.body; try { return res.ok({ @@ -430,15 +569,15 @@ const startTransformsHandler: RequestHandler = async (ctx, req, res) => { }; async function startTransforms( - transformsInfo: TransformEndpointRequest[], + transformsInfo: StartTransformsRequestSchema, callAsCurrentUser: CallCluster ) { - const results: TransformEndpointResult = {}; + const results: StartTransformsResponseSchema = {}; for (const transformInfo of transformsInfo) { const transformId = transformInfo.id; try { - await callAsCurrentUser('transform.startTransform', { transformId } as SchemaTransformId); + await callAsCurrentUser('transform.startTransform', { transformId }); results[transformId] = { success: true }; } catch (e) { if (isRequestTimeout(e)) { @@ -455,8 +594,12 @@ async function startTransforms( return results; } -const stopTransformsHandler: RequestHandler = async (ctx, req, res) => { - const transformsInfo = req.body as TransformEndpointRequest[]; +const stopTransformsHandler: RequestHandler< + undefined, + undefined, + StopTransformsRequestSchema +> = async (ctx, req, res) => { + const transformsInfo = req.body; try { return res.ok({ @@ -468,10 +611,10 @@ const stopTransformsHandler: RequestHandler = async (ctx, req, res) => { }; async function stopTransforms( - transformsInfo: TransformEndpointRequest[], + transformsInfo: StopTransformsRequestSchema, callAsCurrentUser: CallCluster ) { - const results: TransformEndpointResult = {}; + const results: StopTransformsResponseSchema = {}; for (const transformInfo of transformsInfo) { const transformId = transformInfo.id; diff --git a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts index 722a3f52376b4..f01b2bdb73fd5 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditMessage } from '../../../common/types/messages'; +import { transformIdParamSchema, TransformIdParamSchema } from '../../../common/api_schemas/common'; +import { AuditMessage, TransformMessage } from '../../../common/types/messages'; import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; import { RouteDependencies } from '../../types'; @@ -12,7 +13,6 @@ import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; import { wrapError } from './error_utils'; -import { schemaTransformId, SchemaTransformId } from './schema'; const ML_DF_NOTIFICATION_INDEX_PATTERN = '.transform-notifications-read'; const SIZE = 500; @@ -22,10 +22,22 @@ interface BoolQuery { } export function registerTransformsAuditMessagesRoutes({ router, license }: RouteDependencies) { - router.get( - { path: addBasePath('transforms/{transformId}/messages'), validate: schemaTransformId }, - license.guardApiRoute(async (ctx, req, res) => { - const { transformId } = req.params as SchemaTransformId; + /** + * @apiGroup Transforms Audit Messages + * + * @api {get} /api/transform/transforms/:transformId/messages Transforms Messages + * @apiName GetTransformsMessages + * @apiDescription Get transforms audit messages + * + * @apiSchema (params) transformIdParamSchema + */ + router.get( + { + path: addBasePath('transforms/{transformId}/messages'), + validate: { params: transformIdParamSchema }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params; // search for audit messages, // transformId is optional. without it, all transforms will be listed. @@ -77,7 +89,7 @@ export function registerTransformsAuditMessagesRoutes({ router, license }: Route }, }); - let messages = []; + let messages: TransformMessage[] = []; if (resp.hits.total !== 0) { messages = resp.hits.hits.map((hit: AuditMessage) => hit._source); messages.reverse(); diff --git a/x-pack/plugins/transform/server/routes/apidoc.json b/x-pack/plugins/transform/server/routes/apidoc.json new file mode 100644 index 0000000000000..ce76b5b302f93 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/apidoc.json @@ -0,0 +1,21 @@ +{ + "name": "transform_kibana_api", + "version": "7.10.0", + "description": "This is the documentation of the REST API provided by the Transform Kibana plugin. Each API is experimental and can include breaking changes in any version.", + "title": "Transform Kibana API", + "order": [ + "GetTransforms", + "GetTransform", + "GetTransformsStats", + "GetTransformStats", + "PutTransform", + "PostTransformUpdate", + "DeleteTransforms", + "PreviewTransform", + "PostStartTransforms", + "PostStopTransforms", + "PostTransformEsSearchProxy", + "DeleteDataFrameAnalytics", + "GetTransformsMessages" + ] +} diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts index 1a2768999fdc4..bacf9724a6253 100644 --- a/x-pack/plugins/transform/server/services/license.ts +++ b/x-pack/plugins/transform/server/services/license.ts @@ -62,12 +62,12 @@ export class License { }); } - guardApiRoute(handler: RequestHandler) { + guardApiRoute(handler: RequestHandler) { const license = this; return function licenseCheck( ctx: RequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest, response: KibanaResponseFactory ): IKibanaResponse | Promise> { const licenseStatus = license.getStatus(); diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json new file mode 100644 index 0000000000000..6f83eb665f830 --- /dev/null +++ b/x-pack/plugins/transform/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json", +} diff --git a/x-pack/run_functional_tests.sh b/x-pack/run_functional_tests.sh deleted file mode 100755 index e94f283ea0394..0000000000000 --- a/x-pack/run_functional_tests.sh +++ /dev/null @@ -1,3 +0,0 @@ -export TEST_KIBANA_URL="http://elastic:mlqa_admin@localhost:5601" -export TEST_ES_URL="http://elastic:mlqa_admin@localhost:9200" -node ../scripts/functional_test_runner --include-tag walterra diff --git a/x-pack/test/api_integration/apis/transform/common.ts b/x-pack/test/api_integration/apis/transform/common.ts new file mode 100644 index 0000000000000..1a48ee987bc77 --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/common.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PutTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/transforms'; + +export async function asyncForEach(array: any[], callback: Function) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } +} + +export function generateDestIndex(transformId: string): string { + return `user-${transformId}`; +} + +export function generateTransformConfig(transformId: string): PutTransformsRequestSchema { + const destinationIndex = generateDestIndex(transformId); + + return { + source: { index: ['ft_farequote'] }, + pivot: { + group_by: { airline: { terms: { field: 'airline' } } }, + aggregations: { '@timestamp.value_count': { value_count: { field: '@timestamp' } } }, + }, + dest: { index: destinationIndex }, + }; +} diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts index 7f01d2741ad15..41b2bffb1f0ad 100644 --- a/x-pack/test/api_integration/apis/transform/delete_transforms.ts +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -4,41 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; -import { TransformEndpointRequest } from '../../../../plugins/transform/common'; -import { FtrProviderContext } from '../../ftr_provider_context'; + +import { DeleteTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/delete_transforms'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; import { USER } from '../../../functional/services/transform/security_common'; -async function asyncForEach(array: any[], callback: Function) { - for (let index = 0; index < array.length; index++) { - await callback(array[index], index, array); - } -} +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { asyncForEach, generateDestIndex, generateTransformConfig } from './common'; export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertestWithoutAuth'); const transform = getService('transform'); - function generateDestIndex(transformId: string): string { - return `user-${transformId}`; + async function createTransform(transformId: string) { + const config = generateTransformConfig(transformId); + await transform.api.createTransform(transformId, config); } - async function createTransform(transformId: string, destinationIndex: string) { - const config = { - id: transformId, - source: { index: ['farequote-*'] }, - pivot: { - group_by: { airline: { terms: { field: 'airline' } } }, - aggregations: { '@timestamp.value_count': { value_count: { field: '@timestamp' } } }, - }, - dest: { index: destinationIndex }, - }; - - await transform.api.createTransform(config); - } - - describe('delete_transforms', function () { + describe('/api/transform/delete_transforms', function () { before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); await transform.testResources.setKibanaTimeZoneToUTC(); @@ -49,11 +36,11 @@ export default ({ getService }: FtrProviderContext) => { }); describe('single transform deletion', function () { - const transformId = 'test1'; + const transformId = 'transform-test-delete'; const destinationIndex = generateDestIndex(transformId); beforeEach(async () => { - await createTransform(transformId, destinationIndex); + await createTransform(transformId); await transform.api.createIndices(destinationIndex); }); @@ -62,7 +49,9 @@ export default ({ getService }: FtrProviderContext) => { }); it('should delete transform by transformId', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], + }; const { body } = await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -70,9 +59,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - }) + .send(reqBody) .expect(200); expect(body[transformId].transformDeleted.success).to.eql(true); @@ -83,7 +70,9 @@ export default ({ getService }: FtrProviderContext) => { }); it('should return 403 for unauthorized user', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], + }; await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -91,9 +80,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - }) + .send(reqBody) .expect(403); await transform.api.waitForTransformToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); @@ -102,7 +89,9 @@ export default ({ getService }: FtrProviderContext) => { describe('single transform deletion with invalid transformId', function () { it('should return 200 with error in response if invalid transformId', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: 'invalid_transform_id' }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: 'invalid_transform_id', state: TRANSFORM_STATE.STOPPED }], + }; const { body } = await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -110,9 +99,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - }) + .send(reqBody) .expect(200); expect(body.invalid_transform_id.transformDeleted.success).to.eql(false); expect(body.invalid_transform_id.transformDeleted).to.have.property('error'); @@ -120,15 +107,17 @@ export default ({ getService }: FtrProviderContext) => { }); describe('bulk deletion', function () { - const transformsInfo: TransformEndpointRequest[] = [ - { id: 'bulk_delete_test_1' }, - { id: 'bulk_delete_test_2' }, - ]; - const destinationIndices = transformsInfo.map((d) => generateDestIndex(d.id)); + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [ + { id: 'bulk_delete_test_1', state: TRANSFORM_STATE.STOPPED }, + { id: 'bulk_delete_test_2', state: TRANSFORM_STATE.STOPPED }, + ], + }; + const destinationIndices = reqBody.transformsInfo.map((d) => generateDestIndex(d.id)); beforeEach(async () => { - await asyncForEach(transformsInfo, async ({ id }: { id: string }, idx: number) => { - await createTransform(id, destinationIndices[idx]); + await asyncForEach(reqBody.transformsInfo, async ({ id }: { id: string }, idx: number) => { + await createTransform(id); await transform.api.createIndices(destinationIndices[idx]); }); }); @@ -147,13 +136,11 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - }) + .send(reqBody) .expect(200); await asyncForEach( - transformsInfo, + reqBody.transformsInfo, async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); @@ -174,16 +161,16 @@ export default ({ getService }: FtrProviderContext) => { ) .set(COMMON_REQUEST_HEADERS) .send({ + ...reqBody, transformsInfo: [ - { id: transformsInfo[0].id }, - { id: invalidTransformId }, - { id: transformsInfo[1].id }, + ...reqBody.transformsInfo, + { id: invalidTransformId, state: TRANSFORM_STATE.STOPPED }, ], }) .expect(200); await asyncForEach( - transformsInfo, + reqBody.transformsInfo, async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); @@ -203,7 +190,7 @@ export default ({ getService }: FtrProviderContext) => { const destinationIndex = generateDestIndex(transformId); before(async () => { - await createTransform(transformId, destinationIndex); + await createTransform(transformId); await transform.api.createIndices(destinationIndex); }); @@ -212,7 +199,10 @@ export default ({ getService }: FtrProviderContext) => { }); it('should delete transform and destination index', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], + deleteDestIndex: true, + }; const { body } = await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -220,10 +210,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - deleteDestIndex: true, - }) + .send(reqBody) .expect(200); expect(body[transformId].transformDeleted.success).to.eql(true); @@ -239,7 +226,7 @@ export default ({ getService }: FtrProviderContext) => { const destinationIndex = generateDestIndex(transformId); before(async () => { - await createTransform(transformId, destinationIndex); + await createTransform(transformId); await transform.api.createIndices(destinationIndex); await transform.testResources.createIndexPatternIfNeeded(destinationIndex); }); @@ -250,7 +237,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should delete transform and destination index pattern', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], + deleteDestIndex: false, + deleteDestIndexPattern: true, + }; const { body } = await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -258,11 +249,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - deleteDestIndex: false, - deleteDestIndexPattern: true, - }) + .send(reqBody) .expect(200); expect(body[transformId].transformDeleted.success).to.eql(true); @@ -279,7 +266,7 @@ export default ({ getService }: FtrProviderContext) => { const destinationIndex = generateDestIndex(transformId); before(async () => { - await createTransform(transformId, destinationIndex); + await createTransform(transformId); await transform.api.createIndices(destinationIndex); await transform.testResources.createIndexPatternIfNeeded(destinationIndex); }); @@ -290,7 +277,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should delete transform, destination index, & destination index pattern', async () => { - const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const reqBody: DeleteTransformsRequestSchema = { + transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], + deleteDestIndex: true, + deleteDestIndexPattern: true, + }; const { body } = await supertest .post(`/api/transform/delete_transforms`) .auth( @@ -298,11 +289,7 @@ export default ({ getService }: FtrProviderContext) => { transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) ) .set(COMMON_REQUEST_HEADERS) - .send({ - transformsInfo, - deleteDestIndex: true, - deleteDestIndexPattern: true, - }) + .send(reqBody) .expect(200); expect(body[transformId].transformDeleted.success).to.eql(true); diff --git a/x-pack/test/api_integration/apis/transform/index.ts b/x-pack/test/api_integration/apis/transform/index.ts index 93a951a55ece1..ef08883534d10 100644 --- a/x-pack/test/api_integration/apis/transform/index.ts +++ b/x-pack/test/api_integration/apis/transform/index.ts @@ -28,5 +28,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); loadTestFile(require.resolve('./delete_transforms')); + loadTestFile(require.resolve('./start_transforms')); + loadTestFile(require.resolve('./stop_transforms')); + loadTestFile(require.resolve('./transforms')); + loadTestFile(require.resolve('./transforms_preview')); + loadTestFile(require.resolve('./transforms_stats')); + loadTestFile(require.resolve('./transforms_update')); }); } diff --git a/x-pack/test/api_integration/apis/transform/start_transforms.ts b/x-pack/test/api_integration/apis/transform/start_transforms.ts new file mode 100644 index 0000000000000..288a3caae390e --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/start_transforms.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import { StartTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/start_transforms'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { asyncForEach, generateDestIndex, generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + async function createTransform(transformId: string) { + const config = generateTransformConfig(transformId); + await transform.api.createTransform(transformId, config); + } + + describe('/api/transform/start_transforms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + }); + + describe('single transform start', function () { + const transformId = 'transform-test-start'; + const destinationIndex = generateDestIndex(transformId); + + beforeEach(async () => { + await createTransform(transformId); + }); + + afterEach(async () => { + await transform.api.cleanTransformIndices(); + await transform.api.deleteIndices(destinationIndex); + }); + + it('should start the transform by transformId', async () => { + const reqBody: StartTransformsRequestSchema = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/start_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(body[transformId].success).to.eql(true); + expect(typeof body[transformId].error).to.eql('undefined'); + await transform.api.waitForBatchTransformToComplete(transformId); + await transform.api.waitForIndicesToExist(destinationIndex); + }); + + it('should return 200 with success:false for unauthorized user', async () => { + const reqBody: StartTransformsRequestSchema = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/start_transforms`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(body[transformId].success).to.eql(false); + expect(typeof body[transformId].error).to.eql('string'); + + await transform.api.waitForTransformState(transformId, TRANSFORM_STATE.STOPPED); + await transform.api.waitForIndicesNotToExist(destinationIndex); + }); + }); + + describe('single transform start with invalid transformId', function () { + it('should return 200 with error in response if invalid transformId', async () => { + const reqBody: StartTransformsRequestSchema = [{ id: 'invalid_transform_id' }]; + const { body } = await supertest + .post(`/api/transform/start_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(body.invalid_transform_id.success).to.eql(false); + expect(body.invalid_transform_id).to.have.property('error'); + }); + }); + + describe('bulk start', function () { + const reqBody: StartTransformsRequestSchema = [ + { id: 'bulk_start_test_1' }, + { id: 'bulk_start_test_2' }, + ]; + const destinationIndices = reqBody.map((d) => generateDestIndex(d.id)); + + beforeEach(async () => { + await asyncForEach(reqBody, async ({ id }: { id: string }, idx: number) => { + await createTransform(id); + }); + }); + + afterEach(async () => { + await transform.api.cleanTransformIndices(); + await asyncForEach(destinationIndices, async (destinationIndex: string) => { + await transform.api.deleteIndices(destinationIndex); + }); + }); + + it('should start multiple transforms by transformIds', async () => { + const { body } = await supertest + .post(`/api/transform/start_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + await asyncForEach(reqBody, async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].success).to.eql(true); + await transform.api.waitForBatchTransformToComplete(transformId); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + }); + }); + + it('should start multiple transforms by transformIds, even if one of the transformIds is invalid', async () => { + const invalidTransformId = 'invalid_transform_id'; + const { body } = await supertest + .post(`/api/transform/start_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send([{ id: reqBody[0].id }, { id: invalidTransformId }, { id: reqBody[1].id }]) + .expect(200); + + await asyncForEach(reqBody, async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].success).to.eql(true); + await transform.api.waitForBatchTransformToComplete(transformId); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + }); + + expect(body[invalidTransformId].success).to.eql(false); + expect(body[invalidTransformId]).to.have.property('error'); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/stop_transforms.ts b/x-pack/test/api_integration/apis/transform/stop_transforms.ts new file mode 100644 index 0000000000000..4f30db0794ea4 --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/stop_transforms.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import type { PutTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/transforms'; +import type { StopTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/stop_transforms'; +import { isStopTransformsResponseSchema } from '../../../../plugins/transform/common/api_schemas/type_guards'; + +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { asyncForEach, generateDestIndex, generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + async function createAndRunTransform(transformId: string) { + // to be able to test stopping transforms, + // we create a slow continuous transform + // so it doesn't stop automatically. + const config: PutTransformsRequestSchema = { + ...generateTransformConfig(transformId), + settings: { + docs_per_second: 10, + max_page_search_size: 10, + }, + sync: { + time: { field: '@timestamp' }, + }, + }; + + await transform.api.createAndRunTransform(transformId, config); + } + + describe('/api/transform/stop_transforms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + }); + + describe('single transform stop', function () { + const transformId = 'transform-test-stop'; + const destinationIndex = generateDestIndex(transformId); + + beforeEach(async () => { + await createAndRunTransform(transformId); + }); + + afterEach(async () => { + await transform.api.cleanTransformIndices(); + await transform.api.deleteIndices(destinationIndex); + }); + + it('should stop the transform by transformId', async () => { + const reqBody: StopTransformsRequestSchema = [ + { id: transformId, state: TRANSFORM_STATE.STARTED }, + ]; + const { body } = await supertest + .post(`/api/transform/stop_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(isStopTransformsResponseSchema(body)).to.eql(true); + expect(body[transformId].success).to.eql(true); + expect(typeof body[transformId].error).to.eql('undefined'); + await transform.api.waitForTransformState(transformId, TRANSFORM_STATE.STOPPED); + await transform.api.waitForIndicesToExist(destinationIndex); + }); + + it('should return 200 with success:false for unauthorized user', async () => { + const reqBody: StopTransformsRequestSchema = [ + { id: transformId, state: TRANSFORM_STATE.STARTED }, + ]; + const { body } = await supertest + .post(`/api/transform/stop_transforms`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(isStopTransformsResponseSchema(body)).to.eql(true); + expect(body[transformId].success).to.eql(false); + expect(typeof body[transformId].error).to.eql('string'); + + await transform.api.waitForTransformStateNotToBe(transformId, TRANSFORM_STATE.STOPPED); + await transform.api.waitForIndicesToExist(destinationIndex); + }); + }); + + describe('single transform stop with invalid transformId', function () { + it('should return 200 with error in response if invalid transformId', async () => { + const reqBody: StopTransformsRequestSchema = [ + { id: 'invalid_transform_id', state: TRANSFORM_STATE.STARTED }, + ]; + const { body } = await supertest + .post(`/api/transform/stop_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(isStopTransformsResponseSchema(body)).to.eql(true); + expect(body.invalid_transform_id.success).to.eql(false); + expect(body.invalid_transform_id).to.have.property('error'); + }); + }); + + describe('bulk stop', function () { + const reqBody: StopTransformsRequestSchema = [ + { id: 'bulk_stop_test_1', state: TRANSFORM_STATE.STARTED }, + { id: 'bulk_stop_test_2', state: TRANSFORM_STATE.STARTED }, + ]; + const destinationIndices = reqBody.map((d) => generateDestIndex(d.id)); + + beforeEach(async () => { + await asyncForEach(reqBody, async ({ id }: { id: string }, idx: number) => { + await createAndRunTransform(id); + }); + }); + + afterEach(async () => { + await transform.api.cleanTransformIndices(); + await asyncForEach(destinationIndices, async (destinationIndex: string) => { + await transform.api.deleteIndices(destinationIndex); + }); + }); + + it('should stop multiple transforms by transformIds', async () => { + const { body } = await supertest + .post(`/api/transform/stop_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(reqBody) + .expect(200); + + expect(isStopTransformsResponseSchema(body)).to.eql(true); + + await asyncForEach(reqBody, async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].success).to.eql(true); + await transform.api.waitForTransformState(transformId, TRANSFORM_STATE.STOPPED); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + }); + }); + + it('should stop multiple transforms by transformIds, even if one of the transformIds is invalid', async () => { + const invalidTransformId = 'invalid_transform_id'; + const { body } = await supertest + .post(`/api/transform/stop_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send([ + { id: reqBody[0].id, state: reqBody[0].state }, + { id: invalidTransformId, state: TRANSFORM_STATE.STOPPED }, + { id: reqBody[1].id, state: reqBody[1].state }, + ]) + .expect(200); + + expect(isStopTransformsResponseSchema(body)).to.eql(true); + + await asyncForEach(reqBody, async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].success).to.eql(true); + await transform.api.waitForTransformState(transformId, TRANSFORM_STATE.STOPPED); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + }); + + expect(body[invalidTransformId].success).to.eql(false); + expect(body[invalidTransformId]).to.have.property('error'); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/transforms.ts b/x-pack/test/api_integration/apis/transform/transforms.ts new file mode 100644 index 0000000000000..c44c2b58e6207 --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/transforms.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import type { GetTransformsResponseSchema } from '../../../../plugins/transform/common/api_schemas/transforms'; +import { isGetTransformsResponseSchema } from '../../../../plugins/transform/common/api_schemas/type_guards'; +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + const expected = { + apiTransformTransforms: { + count: 2, + transform1: { id: 'transform-test-get-1', destIndex: 'user-transform-test-get-1' }, + transform2: { id: 'transform-test-get-2', destIndex: 'user-transform-test-get-2' }, + typeOfVersion: 'string', + typeOfCreateTime: 'number', + }, + apiTransformTransformsTransformId: { + count: 1, + transform1: { id: 'transform-test-get-1', destIndex: 'user-transform-test-get-1' }, + typeOfVersion: 'string', + typeOfCreateTime: 'number', + }, + }; + + async function createTransform(transformId: string) { + const config = generateTransformConfig(transformId); + await transform.api.createTransform(transformId, config); + } + + function assertTransformsResponseBody(body: GetTransformsResponseSchema) { + expect(isGetTransformsResponseSchema(body)).to.eql(true); + + expect(body.count).to.eql(expected.apiTransformTransforms.count); + expect(body.transforms).to.have.length(expected.apiTransformTransforms.count); + + const transform1 = body.transforms[0]; + expect(transform1.id).to.eql(expected.apiTransformTransforms.transform1.id); + expect(transform1.dest.index).to.eql(expected.apiTransformTransforms.transform1.destIndex); + expect(typeof transform1.version).to.eql(expected.apiTransformTransforms.typeOfVersion); + expect(typeof transform1.create_time).to.eql(expected.apiTransformTransforms.typeOfCreateTime); + + const transform2 = body.transforms[1]; + expect(transform2.id).to.eql(expected.apiTransformTransforms.transform2.id); + expect(transform2.dest.index).to.eql(expected.apiTransformTransforms.transform2.destIndex); + expect(typeof transform2.version).to.eql(expected.apiTransformTransforms.typeOfVersion); + expect(typeof transform2.create_time).to.eql(expected.apiTransformTransforms.typeOfCreateTime); + } + + function assertSingleTransformResponseBody(body: GetTransformsResponseSchema) { + expect(isGetTransformsResponseSchema(body)).to.eql(true); + + expect(body.count).to.eql(expected.apiTransformTransformsTransformId.count); + expect(body.transforms).to.have.length(expected.apiTransformTransformsTransformId.count); + + const transform1 = body.transforms[0]; + expect(transform1.id).to.eql(expected.apiTransformTransformsTransformId.transform1.id); + expect(transform1.dest.index).to.eql( + expected.apiTransformTransformsTransformId.transform1.destIndex + ); + expect(typeof transform1.version).to.eql( + expected.apiTransformTransformsTransformId.typeOfVersion + ); + expect(typeof transform1.create_time).to.eql( + expected.apiTransformTransformsTransformId.typeOfCreateTime + ); + } + + describe('/api/transform/transforms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + await createTransform('transform-test-get-1'); + await createTransform('transform-test-get-2'); + }); + + after(async () => { + await transform.api.cleanTransformIndices(); + }); + + describe('/transforms', function () { + it('should return a list of transforms for super-user', async () => { + const { body } = await supertest + .get('/api/transform/transforms') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertTransformsResponseBody(body); + }); + + it('should return a list of transforms for transform view-only user', async () => { + const { body } = await supertest + .get(`/api/transform/transforms`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertTransformsResponseBody(body); + }); + }); + + describe('/transforms/{transformId}', function () { + it('should return a specific transform configuration for super-user', async () => { + const { body } = await supertest + .get('/api/transform/transforms/transform-test-get-1') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertSingleTransformResponseBody(body); + }); + + it('should return a specific transform configuration transform view-only user', async () => { + const { body } = await supertest + .get(`/api/transform/transforms/transform-test-get-1`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertSingleTransformResponseBody(body); + }); + + it('should report 404 for a non-existing transform', async () => { + await supertest + .get('/api/transform/transforms/the-non-existing-transform') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(404); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/transforms_preview.ts b/x-pack/test/api_integration/apis/transform/transforms_preview.ts new file mode 100644 index 0000000000000..d0fc44cf28fdb --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/transforms_preview.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import type { PostTransformsPreviewRequestSchema } from '../../../../plugins/transform/common/api_schemas/transforms'; + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + const expected = { + apiTransformTransformsPreview: { + previewItemCount: 19, + typeOfGeneratedDestIndex: 'object', + }, + }; + + function getTransformPreviewConfig() { + // passing in an empty string for transform id since we will not use + // it as part of the config request schema. Destructuring will + // remove the `dest` part of the config. + const { dest, ...config } = generateTransformConfig(''); + return config as PostTransformsPreviewRequestSchema; + } + + describe('/api/transform/transforms/_preview', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + await transform.api.waitForIndicesToExist('ft_farequote'); + }); + + it('should return a transform preview', async () => { + const { body } = await supertest + .post('/api/transform/transforms/_preview') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(getTransformPreviewConfig()) + .expect(200); + + expect(body.preview).to.have.length(expected.apiTransformTransformsPreview.previewItemCount); + expect(typeof body.generated_dest_index).to.eql( + expected.apiTransformTransformsPreview.typeOfGeneratedDestIndex + ); + }); + + it('should return 403 for transform view-only user', async () => { + await supertest + .post(`/api/transform/transforms/_preview`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(getTransformPreviewConfig()) + .expect(403); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/transforms_stats.ts b/x-pack/test/api_integration/apis/transform/transforms_stats.ts new file mode 100644 index 0000000000000..07856e5095a98 --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/transforms_stats.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import type { GetTransformsStatsResponseSchema } from '../../../../plugins/transform/common/api_schemas/transforms_stats'; +import { isGetTransformsStatsResponseSchema } from '../../../../plugins/transform/common/api_schemas/type_guards'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + const expected = { + apiTransformTransforms: { + count: 2, + transform1: { id: 'transform-test-stats-1', state: TRANSFORM_STATE.STOPPED }, + transform2: { id: 'transform-test-stats-2', state: TRANSFORM_STATE.STOPPED }, + typeOfStats: 'object', + typeOfCheckpointing: 'object', + }, + }; + + async function createTransform(transformId: string) { + const config = generateTransformConfig(transformId); + await transform.api.createTransform(transformId, config); + } + + function assertTransformsStatsResponseBody(body: GetTransformsStatsResponseSchema) { + expect(isGetTransformsStatsResponseSchema(body)).to.eql(true); + expect(body.count).to.eql(expected.apiTransformTransforms.count); + expect(body.transforms).to.have.length(expected.apiTransformTransforms.count); + + const transform1 = body.transforms[0]; + expect(transform1.id).to.eql(expected.apiTransformTransforms.transform1.id); + expect(transform1.state).to.eql(expected.apiTransformTransforms.transform1.state); + expect(typeof transform1.stats).to.eql(expected.apiTransformTransforms.typeOfStats); + expect(typeof transform1.checkpointing).to.eql( + expected.apiTransformTransforms.typeOfCheckpointing + ); + + const transform2 = body.transforms[1]; + expect(transform2.id).to.eql(expected.apiTransformTransforms.transform2.id); + expect(transform2.state).to.eql(expected.apiTransformTransforms.transform2.state); + expect(typeof transform2.stats).to.eql(expected.apiTransformTransforms.typeOfStats); + expect(typeof transform2.checkpointing).to.eql( + expected.apiTransformTransforms.typeOfCheckpointing + ); + } + + describe('/api/transform/transforms/_stats', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + await createTransform('transform-test-stats-1'); + await createTransform('transform-test-stats-2'); + }); + + after(async () => { + await transform.api.cleanTransformIndices(); + }); + + it('should return a list of transforms statistics for super-user', async () => { + const { body } = await supertest + .get('/api/transform/transforms/_stats') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertTransformsStatsResponseBody(body); + }); + + it('should return a list of transforms statistics view-only user', async () => { + const { body } = await supertest + .get(`/api/transform/transforms/_stats`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + assertTransformsStatsResponseBody(body); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/transforms_update.ts b/x-pack/test/api_integration/apis/transform/transforms_update.ts new file mode 100644 index 0000000000000..3ad5b5b47c79b --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/transforms_update.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api'; +import { USER } from '../../../functional/services/transform/security_common'; + +import { generateTransformConfig } from './common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + const expected = { + transformOriginalConfig: { + count: 1, + id: 'transform-test-update-1', + source: { + index: ['ft_farequote'], + query: { match_all: {} }, + }, + }, + apiTransformTransformsPreview: { + previewItemCount: 19, + typeOfGeneratedDestIndex: 'object', + }, + }; + + async function createTransform(transformId: string) { + const config = generateTransformConfig(transformId); + await transform.api.createTransform(transformId, config); + } + + function getTransformUpdateConfig() { + return { + source: { + index: 'ft_*', + query: { + term: { + airline: { + value: 'AAL', + }, + }, + }, + }, + description: 'the-updated-description', + dest: { + index: 'user-the-updated-destination-index', + }, + frequency: '60m', + }; + } + + describe('/api/transform/transforms/{transformId}/_update', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + await createTransform('transform-test-update-1'); + }); + + after(async () => { + await transform.api.cleanTransformIndices(); + }); + + it('should update a transform', async () => { + // assert the original transform for comparison + const { body: transformOriginalBody } = await supertest + .get('/api/transform/transforms/transform-test-update-1') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + expect(transformOriginalBody.count).to.eql(expected.transformOriginalConfig.count); + expect(transformOriginalBody.transforms).to.have.length( + expected.transformOriginalConfig.count + ); + + const transformOriginalConfig = transformOriginalBody.transforms[0]; + expect(transformOriginalConfig.id).to.eql(expected.transformOriginalConfig.id); + expect(transformOriginalConfig.source).to.eql(expected.transformOriginalConfig.source); + expect(transformOriginalConfig.description).to.eql(undefined); + expect(transformOriginalConfig.settings).to.eql({}); + + // update the transform and assert the response + const { body: transformUpdateResponseBody } = await supertest + .post('/api/transform/transforms/transform-test-update-1/_update') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(getTransformUpdateConfig()) + .expect(200); + + const expectedUpdateConfig = getTransformUpdateConfig(); + expect(transformUpdateResponseBody.id).to.eql(expected.transformOriginalConfig.id); + expect(transformUpdateResponseBody.source).to.eql({ + ...expectedUpdateConfig.source, + index: ['ft_*'], + }); + expect(transformUpdateResponseBody.description).to.eql(expectedUpdateConfig.description); + expect(transformUpdateResponseBody.settings).to.eql({}); + + // assert the updated transform for comparison + const { body: transformUpdatedBody } = await supertest + .get('/api/transform/transforms/transform-test-update-1') + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send() + .expect(200); + + expect(transformUpdatedBody.count).to.eql(expected.transformOriginalConfig.count); + expect(transformUpdatedBody.transforms).to.have.length( + expected.transformOriginalConfig.count + ); + + const transformUpdatedConfig = transformUpdatedBody.transforms[0]; + expect(transformUpdatedConfig.id).to.eql(expected.transformOriginalConfig.id); + expect(transformUpdatedConfig.source).to.eql({ + ...expectedUpdateConfig.source, + index: ['ft_*'], + }); + expect(transformUpdatedConfig.description).to.eql(expectedUpdateConfig.description); + expect(transformUpdatedConfig.settings).to.eql({}); + }); + + it('should return 403 for transform view-only user', async () => { + await supertest + .post('/api/transform/transforms/transform-test-update-1/_update') + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send(getTransformUpdateConfig()) + .expect(403); + }); + }); +}; diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index b6ccd68bb2096..a147b56d56251 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -5,12 +5,12 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -import { TransformPivotConfig } from '../../../../plugins/transform/public/app/common'; +import { TransformPivotConfig } from '../../../../plugins/transform/common/types/transform'; function getTransformConfig(): TransformPivotConfig { const date = Date.now(); return { - id: `ec_2_${date}`, + id: `ec_cloning_${date}`, source: { index: ['ft_ecommerce'] }, pivot: { group_by: { category: { terms: { field: 'category.keyword' } } }, @@ -32,7 +32,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/ecommerce'); await transform.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); - await transform.api.createAndRunTransform(transformConfig); + await transform.api.createAndRunTransform(transformConfig.id, transformConfig); await transform.testResources.setKibanaTimeZoneToUTC(); await transform.securityUI.loginAsTransformPowerUser(); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 4e2b832838b7d..13213679a6117 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + import { FtrProviderContext } from '../../ftr_provider_context'; interface GroupByEntry { @@ -141,7 +143,7 @@ export default function ({ getService }: FtrProviderContext) { values: [`Men's Accessories`], }, row: { - status: 'stopped', + status: TRANSFORM_STATE.STOPPED, mode: 'batch', progress: '100', }, @@ -239,7 +241,7 @@ export default function ({ getService }: FtrProviderContext) { values: ['AE', 'CO', 'EG', 'FR', 'GB'], }, row: { - status: 'stopped', + status: TRANSFORM_STATE.STOPPED, mode: 'batch', progress: '100', }, diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index 229ff97782362..20d276c2e017b 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + import { FtrProviderContext } from '../../ftr_provider_context'; interface GroupByEntry { @@ -58,7 +60,7 @@ export default function ({ getService }: FtrProviderContext) { values: ['ASA'], }, row: { - status: 'stopped', + status: TRANSFORM_STATE.STOPPED, mode: 'batch', progress: '100', }, diff --git a/x-pack/test/functional/apps/transform/editing.ts b/x-pack/test/functional/apps/transform/editing.ts index 460e7c5b24a98..ac955bde4ad5d 100644 --- a/x-pack/test/functional/apps/transform/editing.ts +++ b/x-pack/test/functional/apps/transform/editing.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TransformPivotConfig } from '../../../../plugins/transform/common/types/transform'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; + import { FtrProviderContext } from '../../ftr_provider_context'; -import { TransformPivotConfig } from '../../../../plugins/transform/public/app/common'; function getTransformConfig(): TransformPivotConfig { const date = Date.now(); return { - id: `ec_2_${date}`, + id: `ec_editing_${date}`, source: { index: ['ft_ecommerce'] }, pivot: { group_by: { category: { terms: { field: 'category.keyword' } } }, @@ -32,7 +34,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/ecommerce'); await transform.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); - await transform.api.createAndRunTransform(transformConfig); + await transform.api.createAndRunTransform(transformConfig.id, transformConfig); await transform.testResources.setKibanaTimeZoneToUTC(); await transform.securityUI.loginAsTransformPowerUser(); @@ -52,7 +54,7 @@ export default function ({ getService }: FtrProviderContext) { expected: { messageText: 'updated transform.', row: { - status: 'stopped', + status: TRANSFORM_STATE.STOPPED, mode: 'batch', progress: '100', }, diff --git a/x-pack/test/functional/services/transform/api.ts b/x-pack/test/functional/services/transform/api.ts index 697020fafb196..d97db93c31b3b 100644 --- a/x-pack/test/functional/services/transform/api.ts +++ b/x-pack/test/functional/services/transform/api.ts @@ -5,13 +5,17 @@ */ import expect from '@kbn/expect'; +import type { PutTransformsRequestSchema } from '../../../../plugins/transform/common/api_schemas/transforms'; +import { TransformState, TRANSFORM_STATE } from '../../../../plugins/transform/common/constants'; +import type { TransformStats } from '../../../../plugins/transform/common/types/transform_stats'; + import { FtrProviderContext } from '../../ftr_provider_context'; -import { TRANSFORM_STATE } from '../../../../plugins/transform/common'; -import { - TransformPivotConfig, - TransformStats, -} from '../../../../plugins/transform/public/app/common'; +export async function asyncForEach(array: any[], callback: Function) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } +} export function TransformAPIProvider({ getService }: FtrProviderContext) { const es = getService('legacyEs'); @@ -35,7 +39,7 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { await this.waitForIndicesToExist(indices, `expected ${indices} to be created`); }, - async deleteIndices(indices: string) { + async deleteIndices(indices: string, skipWaitForIndicesNotToExist?: boolean) { log.debug(`Deleting indices: '${indices}'...`); if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === false) { log.debug(`Indices '${indices}' don't exist. Nothing to delete.`); @@ -49,7 +53,13 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { .to.have.property('acknowledged') .eql(true, 'Response for delete request should be acknowledged'); - await this.waitForIndicesNotToExist(indices, `expected indices '${indices}' to be deleted`); + // Check for the option to skip the check if the indices are deleted. + // For example, we might want to clear the .transform-* indices but they + // will be automatically regenerated making tests flaky without the option + // to skip this check. + if (!skipWaitForIndicesNotToExist) { + await this.waitForIndicesNotToExist(indices, `expected indices '${indices}' to be deleted`); + } }, async waitForIndicesToExist(indices: string, errorMsg?: string) { @@ -73,7 +83,26 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { }, async cleanTransformIndices() { - await this.deleteIndices('.transform-*'); + // Delete all transforms using the API since we mustn't just delete + // all `.transform-*` indices since this might result in orphaned ES tasks. + const { + body: { transforms }, + } = await esSupertest.get(`/_transform/`).expect(200); + const transformIds = transforms.map((t: { id: string }) => t.id); + + await asyncForEach(transformIds, async (transformId: string) => { + await esSupertest + .post(`/_transform/${transformId}/_stop?force=true&wait_for_completion`) + .expect(200); + await this.waitForTransformState(transformId, TRANSFORM_STATE.STOPPED); + + await esSupertest.delete(`/_transform/${transformId}`).expect(200); + await this.waitForTransformNotToExist(transformId); + }); + + // Delete all transform related notifications to clear messages tabs + // in the transforms list expanded rows. + await this.deleteIndices('.transform-notifications-*'); }, async getTransformStats(transformId: string): Promise { @@ -90,12 +119,12 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { return statsResponse.transforms[0]; }, - async getTransformState(transformId: string): Promise { + async getTransformState(transformId: string): Promise { const stats = await this.getTransformStats(transformId); return stats.state; }, - async waitForTransformState(transformId: string, expectedState: TRANSFORM_STATE) { + async waitForTransformState(transformId: string, expectedState: TransformState) { await retry.waitForWithTimeout( `transform state to be ${expectedState}`, 2 * 60 * 1000, @@ -110,6 +139,23 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { ); }, + async waitForTransformStateNotToBe(transformId: string, notExpectedState: TransformState) { + await retry.waitForWithTimeout( + `transform state not to be ${notExpectedState}`, + 2 * 60 * 1000, + async () => { + const state = await this.getTransformState(transformId); + if (state !== notExpectedState) { + return true; + } else { + throw new Error( + `expected transform state to not be ${notExpectedState} but got ${state}` + ); + } + } + ); + }, + async waitForBatchTransformToComplete(transformId: string) { await retry.waitForWithTimeout(`batch transform to complete`, 2 * 60 * 1000, async () => { const stats = await this.getTransformStats(transformId); @@ -127,8 +173,7 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { return await esSupertest.get(`/_transform/${transformId}`).expect(expectedCode); }, - async createTransform(transformConfig: TransformPivotConfig) { - const transformId = transformConfig.id; + async createTransform(transformId: string, transformConfig: PutTransformsRequestSchema) { log.debug(`Creating transform with id '${transformId}'...`); await esSupertest.put(`/_transform/${transformId}`).send(transformConfig).expect(200); @@ -147,6 +192,7 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { } }); }, + async waitForTransformNotToExist(transformId: string, errorMsg?: string) { await retry.waitForWithTimeout(`'${transformId}' to exist`, 5 * 1000, async () => { if (await this.getTransform(transformId, 404)) { @@ -162,15 +208,15 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { await esSupertest.post(`/_transform/${transformId}/_start`).expect(200); }, - async createAndRunTransform(transformConfig: TransformPivotConfig) { - await this.createTransform(transformConfig); - await this.startTransform(transformConfig.id); + async createAndRunTransform(transformId: string, transformConfig: PutTransformsRequestSchema) { + await this.createTransform(transformId, transformConfig); + await this.startTransform(transformId); if (transformConfig.sync === undefined) { // batch mode - await this.waitForBatchTransformToComplete(transformConfig.id); + await this.waitForBatchTransformToComplete(transformId); } else { // continuous mode - await this.waitForTransformState(transformConfig.id, TRANSFORM_STATE.STARTED); + await this.waitForTransformStateNotToBe(transformId, TRANSFORM_STATE.STOPPED); } }, }; diff --git a/x-pack/test/functional/services/transform/transform_table.ts b/x-pack/test/functional/services/transform/transform_table.ts index 77e52b642261b..cc360379f32c3 100644 --- a/x-pack/test/functional/services/transform/transform_table.ts +++ b/x-pack/test/functional/services/transform/transform_table.ts @@ -174,7 +174,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { await testSubjects.existOrFail('transformMessagesTab'); await testSubjects.click('transformMessagesTab'); await testSubjects.existOrFail('~transformMessagesTabContent'); - await retry.tryForTime(5000, async () => { + await retry.tryForTime(30 * 1000, async () => { const actualText = await testSubjects.getVisibleText('~transformMessagesTabContent'); expect(actualText.includes(expectedText)).to.eql( true, From 8eb4b2e471dafb8fdde98376d34a73cce125026d Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Mon, 14 Sep 2020 17:39:11 +0300 Subject: [PATCH 07/91] [telemetry] add schema guideline + schema_check new check for --path config (#75747) --- packages/kbn-telemetry-tools/GUIDELINE.md | 211 ++++++++++++++++++ .../src/cli/run_telemetry_check.ts | 27 ++- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-telemetry-tools/GUIDELINE.md diff --git a/packages/kbn-telemetry-tools/GUIDELINE.md b/packages/kbn-telemetry-tools/GUIDELINE.md new file mode 100644 index 0000000000000..e7d09babbf9e2 --- /dev/null +++ b/packages/kbn-telemetry-tools/GUIDELINE.md @@ -0,0 +1,211 @@ +# Collector Schema Guideline + +Table of contents: +- [Collector Schema Guideline](#collector-schema-guideline) + - [Adding schema to your collector](#adding-schema-to-your-collector) + - [1. Update the telemetryrc file](#1-update-the-telemetryrc-file) + - [2. Type the `fetch` function](#2-type-the-fetch-function) + - [3. Add a `schema` field](#3-add-a-schema-field) + - [4. Run the telemetry check](#4-run-the-telemetry-check) + - [5. Update the stored json files](#5-update-the-stored-json-files) + - [Updating the collector schema](#updating-the-collector-schema) + - [Writing the schema](#writing-the-schema) + - [Basics](#basics) + - [Allowed types](#allowed-types) + - [Dealing with arrays](#dealing-with-arrays) + - [Schema Restrictions](#schema-restrictions) + - [Root of schema can only be an object](#root-of-schema-can-only-be-an-object) + + +## Adding schema to your collector + +To add a `schema` to the collector, follow these steps until the telemetry check passes. +To check the next step needed simply run the telemetry check with the path of your collector: + +``` +node scripts/telemetry_check.js --path=.ts +``` + +### 1. Update the telemetryrc file + +Make sure your collector is not excluded in the `telemetryrc.json` files (located at the root of the kibana project, and another on in the `x-pack` dir). + +```s +[ + { + ... + "exclude": [ + "" + ] + } +] +``` + +Note that the check will fail if the collector in --path is excluded. + +### 2. Type the `fetch` function +1. Make sure the return of the `fetch` function is typed. + +The function `makeUsageCollector` accepts a generic type parameter of the returned type of the `fetch` function. + +``` +interface Usage { + someStat: number; +} + +usageCollection.makeUsageCollector({ + fetch: async () => { + return { + someStat: 3, + } + }, + ... +}) +``` + +The generic type passed to `makeUsageCollector` will automatically unwrap the `Promise` to check for the resolved type. + +### 3. Add a `schema` field + +Add a `schema` field to your collector. After passing the return type of the fetch function to the `makeUsageCollector` generic parameter. It will automaticallly figure out the correct type of the schema based on that provided type. + + +``` +interface Usage { + someStat: number; +} + +usageCollection.makeUsageCollector({ + schema: { + someStat: { + type: 'long' + } + }, + ... +}) +``` + +For full details on writing the `schema` object, check the [Writing the schema](#writing-the-schema) section. + +### 4. Run the telemetry check + +To make sure your changes pass the telemetry check you can run the following: + +``` +node scripts/telemetry_check.js --ignore-stored-json --path=.ts +``` + +### 5. Update the stored json files + +The `--fix` flag will automatically update the persisted json files used by the telemetry team. + +``` +node scripts/telemetry_check.js --fix +``` + +Note that any updates to the stored json files will require a review by the kibana-telemetry team to help us update the telemetry cluster mappings and ensure your changes adhere to our best practices. + + +## Updating the collector schema + +Simply update the fetch function to start returning the updated fields back to our cluster. The update the schema to accomodate these changes. + +Once youre run the changes to both the `fetch` function and the `schema` field run the following command + +``` +node scripts/telemetry_check.js --fix +``` + +The `--fix` flag will automatically update the persisted json files used by the telemetry team. Note that any updates to the stored json files will require a review by the kibana-telemetry team to help us update the telemetry cluster mappings and ensure your changes adhere to our best practices. + + +## Writing the schema + +We've designed the schema object to closely resemble elasticsearch mapping object to reduce any cognitive complexity. + +### Basics + +The function `makeUsageCollector` will automatically translate the returned `Usage` fetch type to the `schema` object. This way you'll have the typescript type checker helping you write the correct corrisponding schema. + +``` +interface Usage { + someStat: number; +} + +usageCollection.makeUsageCollector({ + schema: { + someStat: { + type: 'long' + } + }, + ... +}) +``` + + +### Allowed types + +Any field property in the schema accepts a `type` field. By default the type is `object` which accepts nested properties under it. Currently we accept the following property types: + +``` +AllowedSchemaTypes = + | 'keyword' + | 'text' + | 'number' + | 'boolean' + | 'long' + | 'date' + | 'float'; +``` + + +### Dealing with arrays + +You can optionally define a property to be an array by setting the `isArray` to `true`. Note that the `isArray` property is not currently required. + + +``` +interface Usage { + arrayOfStrings: string[]; + arrayOfObjects: {key: string; value: number; }[]; +} + +usageCollection.makeUsageCollector({ + fetch: () => { + return { + arrayOfStrings: ['item_one', 'item_two'], + arrayOfObjects: [ + { key: 'key_one', value: 13 }, + ] + } + } + schema: { + arrayOfStrings: { + type: 'keyword', + isArray: true, + }, + arrayOfObjects: { + isArray: true, + key: { + type: 'keyword', + }, + value: { + type: 'long', + }, + } + }, + ... +}) +``` + +Be careful adding arrays of objects due to the limitation in correlating the properties inside those objects inside kibana. It is advised to look for an alternative schema based on your use cases. + + +## Schema Restrictions + +We have enforced some restrictions to the schema object to adhere to our telemetry best practices. These practices are derived from the usablity of the sent data in our telemetry cluster. + + +### Root of schema can only be an object + +The root of the schema can only be an object. Currently any property must be nested inside the main schema object. \ No newline at end of file diff --git a/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts b/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts index 2f85fd2cdd2a4..87ba68c1bcb27 100644 --- a/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts +++ b/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts @@ -35,7 +35,7 @@ import { export function runTelemetryCheck() { run( - async ({ flags: { fix = false, path }, log }) => { + async ({ flags: { fix = false, 'ignore-stored-json': ignoreStoredJson, path }, log }) => { if (typeof fix !== 'boolean') { throw createFailError(`${chalk.white.bgRed(' TELEMETRY ERROR ')} --fix can't have a value`); } @@ -50,6 +50,14 @@ export function runTelemetryCheck() { ); } + if (fix && typeof ignoreStoredJson !== 'undefined') { + throw createFailError( + `${chalk.white.bgRed( + ' TELEMETRY ERROR ' + )} --fix is incompatible with --ignore-stored-json flag.` + ); + } + const list = new Listr([ { title: 'Checking .telemetryrc.json files', @@ -59,11 +67,28 @@ export function runTelemetryCheck() { title: 'Extracting Collectors', task: (context) => new Listr(extractCollectorsTask(context, path), { exitOnError: true }), }, + { + enabled: () => typeof path !== 'undefined', + title: 'Checking collectors in --path are not excluded', + task: ({ roots }: TaskContext) => { + const totalCollections = roots.reduce((acc, root) => { + return acc + (root.parsedCollections?.length || 0); + }, 0); + const collectorsInPath = Array.isArray(path) ? path.length : 1; + + if (totalCollections !== collectorsInPath) { + throw new Error( + 'Collector specified in `path` is excluded; Check the telemetryrc.json files.' + ); + } + }, + }, { title: 'Checking Compatible collector.schema with collector.fetch type', task: (context) => new Listr(checkCompatibleTypesTask(context), { exitOnError: true }), }, { + enabled: (_) => !!ignoreStoredJson, title: 'Checking Matching collector.schema against stored json files', task: (context) => new Listr(checkMatchingSchemasTask(context, !fix), { exitOnError: true }), From c5358f3a9b034b93899bdbf988a0bac5212b6d6d Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 14 Sep 2020 16:59:09 +0200 Subject: [PATCH 08/91] [ML] Fix custom URLs processing for security app (#76957) * [ML] fix custom urls processing for security app * [ML] improve query string parsing * [ML] remove escaping with !, adjust a unit test for security app * [ML] unit test * [ML] unit test --- .../application/util/custom_url_utils.test.ts | 109 +++++++++++++- .../application/util/custom_url_utils.ts | 137 ++++++++++-------- 2 files changed, 186 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts index 2912aad6819cf..6a5583ecbb8ac 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -61,8 +61,13 @@ describe('ML - custom URL utils', () => { influencer_field_name: 'airline', influencer_field_values: ['<>:;[}")'], }, + { + influencer_field_name: 'odd:field,name', + influencer_field_values: [">:&12<'"], + }, ], airline: ['<>:;[}")'], + 'odd:field,name': [">:&12<'"], }; const TEST_RECORD_MULTIPLE_INFLUENCER_VALUES: CustomUrlAnomalyRecordDoc = { @@ -98,7 +103,7 @@ describe('ML - custom URL utils', () => { url_name: 'Raw data', time_range: 'auto', url_value: - "discover#/?_g=(time:(from:'$earliest$',mode:absolute,to:'$latest$'))&_a=(index:bf6e5860-9404-11e8-8d4c-593f69c47267,query:(language:kuery,query:'airline:\"$airline$\"'))", + "discover#/?_g=(time:(from:'$earliest$',mode:absolute,to:'$latest$'))&_a=(index:bf6e5860-9404-11e8-8d4c-593f69c47267,query:(language:kuery,query:'airline:\"$airline$\" and odd:field,name : $odd:field,name$'))", }; const TEST_DASHBOARD_LUCENE_URL: KibanaUrlConfig = { @@ -263,9 +268,55 @@ describe('ML - custom URL utils', () => { ); }); - test('returns expected URL for a Kibana Discover type URL when record field contains special characters', () => { + test.skip('returns expected URL for a Kibana Discover type URL when record field contains special characters', () => { expect(getUrlForRecord(TEST_DISCOVER_URL, TEST_RECORD_SPECIAL_CHARS)).toBe( - "discover#/?_g=(time:(from:'2017-02-09T15:10:00.000Z',mode:absolute,to:'2017-02-09T17:15:00.000Z'))&_a=(index:bf6e5860-9404-11e8-8d4c-593f69c47267,query:(language:kuery,query:'airline:\"%3C%3E%3A%3B%5B%7D%5C%22)\"'))" + "discover#/?_g=(time:(from:'2017-02-09T15:10:00.000Z',mode:absolute,to:'2017-02-09T17:15:00.000Z'))&_a=(index:bf6e5860-9404-11e8-8d4c-593f69c47267,query:(language:kuery,query:'airline:\"%3C%3E%3A%3B%5B%7D%5C%22)\" and odd:field,name:>:&12<''))" + ); + }); + + test('correctly encodes special characters inside of a query string', () => { + const testUrl = { + url_name: 'Show dashboard', + time_range: 'auto', + url_value: `dashboards#/view/351de820-f2bb-11ea-ab06-cb93221707e9?_a=(filters:!(),query:(language:kuery,query:'at@name:"$at@name$" and singlequote!'name:"$singlequote!'name$"'))&_g=(filters:!(),time:(from:'$earliest$',mode:absolute,to:'$latest$'))`, + }; + + const testRecord = { + job_id: 'spec-char', + result_type: 'record', + probability: 0.0028099428534745633, + multi_bucket_impact: 5, + record_score: 49.00785814424704, + initial_record_score: 49.00785814424704, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1549593000000, + partition_field_name: 'at@name', + partition_field_value: "contains a ' quote", + function: 'mean', + function_description: 'mean', + typical: [1993.2657340111837], + actual: [1808.3334418402778], + field_name: 'metric%$£&!{(]field', + influencers: [ + { + influencer_field_name: "singlequote'name", + influencer_field_values: ["contains a ' quote"], + }, + { + influencer_field_name: 'at@name', + influencer_field_values: ["contains a ' quote"], + }, + ], + "singlequote'name": ["contains a ' quote"], + 'at@name': ["contains a ' quote"], + earliest: '2019-02-08T00:00:00.000Z', + latest: '2019-02-08T23:59:59.999Z', + }; + + expect(getUrlForRecord(testUrl, testRecord)).toBe( + `dashboards#/view/351de820-f2bb-11ea-ab06-cb93221707e9?_a=(filters:!(),query:(language:kuery,query:'at@name:"contains%20a%20!'%20quote" AND singlequote!'name:"contains%20a%20!'%20quote"'))&_g=(filters:!(),time:(from:'2019-02-08T00:00:00.000Z',mode:absolute,to:'2019-02-08T23:59:59.999Z'))` ); }); @@ -405,6 +456,58 @@ describe('ML - custom URL utils', () => { ); }); + test('return expected url for Security app', () => { + const urlConfig = { + url_name: 'Hosts Details by process name', + url_value: + "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }; + + const testRecords = { + job_id: 'rare_process_by_host_linux_ecs', + result_type: 'record', + probability: 0.018122957282324745, + multi_bucket_impact: 0, + record_score: 20.513469583273547, + initial_record_score: 20.513469583273547, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1549043100000, + by_field_name: 'process.name', + by_field_value: 'seq', + partition_field_name: 'host.name', + partition_field_value: 'showcase', + function: 'rare', + function_description: 'rare', + typical: [0.018122957282324745], + actual: [1], + influencers: [ + { + influencer_field_name: 'user.name', + influencer_field_values: ['sophie'], + }, + { + influencer_field_name: 'process.name', + influencer_field_values: ['seq'], + }, + { + influencer_field_name: 'host.name', + influencer_field_values: ['showcase'], + }, + ], + 'process.name': ['seq'], + 'user.name': ['sophie'], + 'host.name': ['showcase'], + earliest: '2019-02-01T16:00:00.000Z', + latest: '2019-02-01T18:59:59.999Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + "security/hosts/ml-hosts/showcase?_g=()&query=(language:kuery,query:'process.name:\"seq\"')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-02-01T16:00:00.000Z',kind:absolute,to:'2019-02-01T18:59:59.999Z')),timeline:(linkTo:!(global),timerange:(from:'2019-02-01T16%3A00%3A00.000Z',kind:absolute,to:'2019-02-01T18%3A59%3A59.999Z')))" + ); + }); + test('removes an empty path component with a trailing slash', () => { const urlConfig = { url_name: 'APM', diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts index 8263def2034aa..18ba1e4ee337b 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts @@ -8,6 +8,7 @@ import { get, flow } from 'lodash'; import moment from 'moment'; +import rison, { RisonObject, RisonValue } from 'rison-node'; import { parseInterval } from '../../../common/util/parse_interval'; import { escapeForElasticsearchQuery, replaceStringTokens } from './string_utils'; @@ -131,13 +132,70 @@ function escapeForKQL(value: string | number): string { type GetResultTokenValue = (v: string) => string; +export const isRisonObject = (value: RisonValue): value is RisonObject => { + return value !== null && typeof value === 'object'; +}; + +const getQueryStringResultProvider = ( + record: CustomUrlAnomalyRecordDoc, + getResultTokenValue: GetResultTokenValue +) => (resultPrefix: string, queryString: string, resultPostfix: string): string => { + const URL_LENGTH_LIMIT = 2000; + + let availableCharactersLeft = URL_LENGTH_LIMIT - resultPrefix.length - resultPostfix.length; + + // URL template might contain encoded characters + const queryFields = queryString + // Split query string by AND operator. + .split(/\sand\s/i) + // Get property name from `influencerField:$influencerField$` string. + .map((v) => String(v.split(/:(.+)?\$/)[0]).trim()); + + const queryParts: string[] = []; + const joinOperator = ' AND '; + + fieldsLoop: for (let i = 0; i < queryFields.length; i++) { + const field = queryFields[i]; + // Use lodash get to allow nested JSON fields to be retrieved. + let tokenValues: string[] | string | null = get(record, field) || null; + if (tokenValues === null) { + continue; + } + tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; + + // Create a pair `influencerField:value`. + // In cases where there are multiple influencer field values for an anomaly + // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. + let result = ''; + for (let j = 0; j < tokenValues.length; j++) { + const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue(tokenValues[j])}"`; + + // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. + if (availableCharactersLeft < part.length) { + if (result.length > 0) { + queryParts.push(j > 0 ? `(${result})` : result); + } + break fieldsLoop; + } + + result += part; + + availableCharactersLeft -= result.length; + } + + if (result.length > 0) { + queryParts.push(tokenValues.length > 1 ? `(${result})` : result); + } + } + return queryParts.join(joinOperator); +}; + /** * Builds a Kibana dashboard or Discover URL from the supplied config, with any * dollar delimited tokens substituted from the supplied anomaly record. */ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) { const urlValue = urlConfig.url_value; - const URL_LENGTH_LIMIT = 2000; const isLuceneQueryLanguage = urlValue.includes('language:lucene'); @@ -145,11 +203,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) ? escapeForElasticsearchQuery : escapeForKQL; - const commonEscapeCallback = flow( - // Kibana URLs used rison encoding, so escape with ! any ! or ' characters - (v: string): string => v.replace(/[!']/g, '!$&'), - encodeURIComponent - ); + const commonEscapeCallback = flow(encodeURIComponent); const replaceSingleTokenValues = (str: string) => { const getResultTokenValue: GetResultTokenValue = flow( @@ -172,65 +226,34 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) return flow( (str: string) => str.replace('$earliest$', record.earliest).replace('$latest$', record.latest), // Process query string content of the URL + decodeURIComponent, (str: string) => { const getResultTokenValue: GetResultTokenValue = flow( queryLanguageEscapeCallback, commonEscapeCallback ); + + const getQueryStringResult = getQueryStringResultProvider(record, getResultTokenValue); + + const match = str.match(/(.+)(\(.*\blanguage:(?:lucene|kuery)\b.*?\))(.+)/); + + if (match !== null && match[2] !== undefined) { + const [, prefix, queryDef, postfix] = match; + + const q = rison.decode(queryDef); + + if (isRisonObject(q) && q.hasOwnProperty('query')) { + const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); + const resultQuery = getQueryStringResult(resultPrefix, q.query as string, resultPostfix); + return `${resultPrefix}${rison.encode({ ...q, query: resultQuery })}${resultPostfix}`; + } + } + return str.replace( - /(.+query:'|.+&kuery=)([^']*)(['&].+)/, + /(.+&kuery=)(.*?)[^!](&.+)/, (fullMatch, prefix: string, queryString: string, postfix: string) => { const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); - - let availableCharactersLeft = - URL_LENGTH_LIMIT - resultPrefix.length - resultPostfix.length; - const queryFields = queryString - // Split query string by AND operator. - .split(/\sand\s/i) - // Get property name from `influencerField:$influencerField$` string. - .map((v) => v.split(':')[0]); - - const queryParts: string[] = []; - const joinOperator = ' AND '; - - fieldsLoop: for (let i = 0; i < queryFields.length; i++) { - const field = queryFields[i]; - // Use lodash get to allow nested JSON fields to be retrieved. - let tokenValues: string[] | string | null = get(record, field) || null; - if (tokenValues === null) { - continue; - } - tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; - - // Create a pair `influencerField:value`. - // In cases where there are multiple influencer field values for an anomaly - // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. - let result = ''; - for (let j = 0; j < tokenValues.length; j++) { - const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue( - tokenValues[j] - )}"`; - - // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. - if (availableCharactersLeft < part.length) { - if (result.length > 0) { - queryParts.push(j > 0 ? `(${result})` : result); - } - break fieldsLoop; - } - - result += part; - - availableCharactersLeft -= result.length; - } - - if (result.length > 0) { - queryParts.push(tokenValues.length > 1 ? `(${result})` : result); - } - } - - const resultQuery = queryParts.join(joinOperator); - + const resultQuery = getQueryStringResult(resultPrefix, queryString, resultPostfix); return `${resultPrefix}${resultQuery}${resultPostfix}`; } ); From 0c3a8c5f4ef4735ba5a784c6e916b3289834682b Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Mon, 14 Sep 2020 17:00:23 +0200 Subject: [PATCH 09/91] updating datatable type (#77320) --- .../expressions/common/expression_types/specs/boolean.ts | 1 - .../common/expression_types/specs/datatable.ts | 9 +++------ .../expressions/common/expression_types/specs/num.ts | 1 - .../expressions/common/expression_types/specs/number.ts | 1 - .../expressions/common/expression_types/specs/string.ts | 1 - 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/plugins/expressions/common/expression_types/specs/boolean.ts b/src/plugins/expressions/common/expression_types/specs/boolean.ts index adbdeafc34fd2..73b0b98eaaf06 100644 --- a/src/plugins/expressions/common/expression_types/specs/boolean.ts +++ b/src/plugins/expressions/common/expression_types/specs/boolean.ts @@ -41,7 +41,6 @@ export const boolean: ExpressionTypeDefinition<'boolean', boolean> = { }, datatable: (value): Datatable => ({ type: 'datatable', - meta: {}, columns: [{ id: 'value', name: 'value', meta: { type: name } }], rows: [{ value }], }), diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index dd3c653878de7..c201e99faeb03 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -52,7 +52,10 @@ export type DatatableRow = Record; export interface DatatableColumnMeta { type: DatatableColumnType; field?: string; + index?: string; params?: SerializableState; + source?: string; + sourceParams?: SerializableState; } /** * This type represents the shape of a column in a `Datatable`. @@ -63,17 +66,11 @@ export interface DatatableColumn { meta: DatatableColumnMeta; } -export interface DatatableMeta { - type?: string; - source?: string; -} - /** * A `Datatable` in Canvas is a unique structure that represents tabulated data. */ export interface Datatable { type: typeof name; - meta?: DatatableMeta; columns: DatatableColumn[]; rows: DatatableRow[]; } diff --git a/src/plugins/expressions/common/expression_types/specs/num.ts b/src/plugins/expressions/common/expression_types/specs/num.ts index 041747f39740b..d208a9dcf73c8 100644 --- a/src/plugins/expressions/common/expression_types/specs/num.ts +++ b/src/plugins/expressions/common/expression_types/specs/num.ts @@ -73,7 +73,6 @@ export const num: ExpressionTypeDefinition<'num', ExpressionValueNum> = { }, datatable: ({ value }): Datatable => ({ type: 'datatable', - meta: {}, columns: [{ id: 'value', name: 'value', meta: { type: 'number' } }], rows: [{ value }], }), diff --git a/src/plugins/expressions/common/expression_types/specs/number.ts b/src/plugins/expressions/common/expression_types/specs/number.ts index c5fdacf3408a1..c30d3fe943d42 100644 --- a/src/plugins/expressions/common/expression_types/specs/number.ts +++ b/src/plugins/expressions/common/expression_types/specs/number.ts @@ -55,7 +55,6 @@ export const number: ExpressionTypeDefinition = { }, datatable: (value): Datatable => ({ type: 'datatable', - meta: {}, columns: [{ id: 'value', name: 'value', meta: { type: 'number' } }], rows: [{ value }], }), diff --git a/src/plugins/expressions/common/expression_types/specs/string.ts b/src/plugins/expressions/common/expression_types/specs/string.ts index 3d52707279bfc..0869e21e455f7 100644 --- a/src/plugins/expressions/common/expression_types/specs/string.ts +++ b/src/plugins/expressions/common/expression_types/specs/string.ts @@ -40,7 +40,6 @@ export const string: ExpressionTypeDefinition = { }, datatable: (value): Datatable => ({ type: 'datatable', - meta: {}, columns: [{ id: 'value', name: 'value', meta: { type: 'string' } }], rows: [{ value }], }), From 7dca537382a830b8f383d7429be6e25edf69ab7d Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 14 Sep 2020 11:25:18 -0400 Subject: [PATCH 10/91] [Ingest pipelines] Forms for processors T-U (#76710) --- .../common_fields/properties_field.tsx | 51 +++++++++++++ .../processors/geoip.tsx | 36 +++------ .../manage_processor_form/processors/index.ts | 4 + .../manage_processor_form/processors/trim.tsx | 29 ++++++++ .../processors/uppercase.tsx | 29 ++++++++ .../processors/url_decode.tsx | 29 ++++++++ .../processors/user_agent.tsx | 73 +++++++++++++++++++ .../shared/map_processor_type_to_form.tsx | 12 ++- 8 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/properties_field.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/trim.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/uppercase.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/url_decode.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/user_agent.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/properties_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/properties_field.tsx new file mode 100644 index 0000000000000..404a80161068c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/properties_field.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { ComboBoxField, FIELD_TYPES, UseField } from '../../../../../../../shared_imports'; + +import { FieldsConfig, to } from '../shared'; + +const fieldsConfig: FieldsConfig = { + properties: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: (v: string[]) => (v.length ? v : undefined), + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.commonFields.propertiesFieldLabel', + { + defaultMessage: 'Properties (optional)', + } + ), + }, +}; + +interface Props { + helpText?: React.ReactNode; + propertyOptions?: EuiComboBoxOptionOption[]; +} + +export const PropertiesField: FunctionComponent = ({ helpText, propertyOptions }) => { + return ( + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx index c0624c988061c..937fa4d3c4d86 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx @@ -9,18 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode } from '@elastic/eui'; -import { - FIELD_TYPES, - UseField, - Field, - ComboBoxField, - ToggleField, -} from '../../../../../../shared_imports'; +import { FIELD_TYPES, UseField, Field, ToggleField } from '../../../../../../shared_imports'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { FieldsConfig, from, to } from './shared'; import { TargetField } from './common_fields/target_field'; +import { PropertiesField } from './common_fields/properties_field'; const fieldsConfig: FieldsConfig = { /* Optional field config */ @@ -42,21 +37,6 @@ const fieldsConfig: FieldsConfig = { ), }, - properties: { - type: FIELD_TYPES.COMBO_BOX, - deserializer: to.arrayOfStrings, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoIPForm.propertiesFieldLabel', { - defaultMessage: 'Properties (optional)', - }), - helpText: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.geoIPForm.propertiesFieldHelpText', - { - defaultMessage: - 'Properties added to the target field. Valid properties depend on the database file used.', - } - ), - }, - first_only: { type: FIELD_TYPES.TOGGLE, defaultValue: true, @@ -95,10 +75,14 @@ export const GeoIP: FunctionComponent = () => { - diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts index e83560b4a44ce..e211d682ab0f0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts @@ -34,5 +34,9 @@ export { SetProcessor } from './set'; export { SetSecurityUser } from './set_security_user'; export { Split } from './split'; export { Sort } from './sort'; +export { Trim } from './trim'; +export { Uppercase } from './uppercase'; +export { UrlDecode } from './url_decode'; +export { UserAgent } from './user_agent'; export { FormFieldsComponent } from './shared'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/trim.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/trim.tsx new file mode 100644 index 0000000000000..aca5a3b4121b5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/trim.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; + +export const Trim: FunctionComponent = () => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/uppercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/uppercase.tsx new file mode 100644 index 0000000000000..336b68f8c2b7b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/uppercase.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; + +export const Uppercase: FunctionComponent = () => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/url_decode.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/url_decode.tsx new file mode 100644 index 0000000000000..196645a89f707 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/url_decode.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; + +export const UrlDecode: FunctionComponent = () => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/user_agent.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/user_agent.tsx new file mode 100644 index 0000000000000..8395833c09f28 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/user_agent.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { FIELD_TYPES, UseField, Field } from '../../../../../../shared_imports'; + +import { FieldsConfig } from './shared'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { PropertiesField } from './common_fields/properties_field'; + +const propertyOptions: EuiComboBoxOptionOption[] = [ + { label: 'name' }, + { label: 'os' }, + { label: 'device' }, + { label: 'original' }, + { label: 'version' }, +]; + +const fieldsConfig: FieldsConfig = { + /* Optional fields config */ + regex_file: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.userAgentForm.regexFileFieldLabel', + { + defaultMessage: 'Regex file (optional)', + } + ), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.userAgentForm.regexFileFieldHelpText', + { + defaultMessage: + 'A filename containing the regular expressions for parsing the user agent string.', + } + ), + }, +}; + +export const UserAgent: FunctionComponent = () => { + return ( + <> + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx index 9de371f8d0024..95a8d35c119a6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx @@ -41,6 +41,10 @@ import { SetSecurityUser, Split, Sort, + Trim, + Uppercase, + UrlDecode, + UserAgent, FormFieldsComponent, } from '../manage_processor_form/processors'; @@ -404,28 +408,28 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }), }, trim: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Trim, docLinkPath: '/trim-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.trim', { defaultMessage: 'Trim', }), }, uppercase: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Uppercase, docLinkPath: '/uppercase-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.uppercase', { defaultMessage: 'Uppercase', }), }, urldecode: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: UrlDecode, docLinkPath: '/urldecode-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.urldecode', { defaultMessage: 'URL decode', }), }, user_agent: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: UserAgent, docLinkPath: '/user-agent-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { defaultMessage: 'User agent', From 1a49c4e20376cc53ec33acb3d750cf0b943c5cd7 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Mon, 14 Sep 2020 11:51:58 -0400 Subject: [PATCH 11/91] [Security Solution][Endpoint][Admin] Task/endpoint list actions (#76555) Endpoint list actions to security solution endpoint admin Co-authored-by: Paul Tavares --- x-pack/plugins/ingest_manager/public/index.ts | 2 + .../scripts/dev_agent/script.ts | 5 + .../use_navigate_to_app_event_handler.ts | 2 +- .../pages/endpoint_hosts/store/action.ts | 6 + .../pages/endpoint_hosts/store/index.test.ts | 1 + .../pages/endpoint_hosts/store/middleware.ts | 77 +++++++--- .../store/mock_endpoint_result_list.ts | 6 +- .../pages/endpoint_hosts/store/reducer.ts | 9 ++ .../pages/endpoint_hosts/store/selectors.ts | 7 + .../management/pages/endpoint_hosts/types.ts | 15 +- .../pages/endpoint_hosts/view/index.test.tsx | 95 +++++++++++- .../pages/endpoint_hosts/view/index.tsx | 142 +++++++++++++++++- .../apps/endpoint/endpoint_list.ts | 8 + 13 files changed, 342 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/index.ts b/x-pack/plugins/ingest_manager/public/index.ts index 75ba0e584230f..730ab59c3eb19 100644 --- a/x-pack/plugins/ingest_manager/public/index.ts +++ b/x-pack/plugins/ingest_manager/public/index.ts @@ -20,3 +20,5 @@ export { export { NewPackagePolicy } from './applications/ingest_manager/types'; export * from './applications/ingest_manager/types/intra_app_route_state'; + +export { pagePathGetters } from './applications/ingest_manager/constants'; diff --git a/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts index 65375a076e9a4..47108508ec68a 100644 --- a/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts +++ b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts @@ -14,7 +14,11 @@ import { PostAgentEnrollRequest, PostAgentEnrollResponse, } from '../../common/types'; +import * as kibanaPackage from '../../package.json'; +// @ts-ignore +// Using the ts-ignore because we are importing directly from a json to a script file +const version = kibanaPackage.version; const CHECKIN_INTERVAL = 3000; // 3 seconds type Agent = Pick<_Agent, 'id' | 'access_api_key'>; @@ -104,6 +108,7 @@ async function enroll(kibanaURL: string, apiKey: string, log: ToolingLog): Promi ip: '127.0.0.1', system: `${os.type()} ${os.release()}`, memory: os.totalmem(), + elastic: { agent: { version } }, }, user_provided: { dev_agent_version: '0.0.1', diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts index 190009440529c..943b30925a54c 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts @@ -12,7 +12,7 @@ type NavigateToAppHandlerOptions = NavigateToAppOptions & { state?: S; onClick?: EventHandlerCallback; }; -type EventHandlerCallback = MouseEventHandler; +type EventHandlerCallback = MouseEventHandler; /** * Provides an event handlers that can be used with (for example) `onClick` to prevent the diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 84d09adfc295e..c2a838404b0bb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -82,6 +82,11 @@ interface ServerReturnedEndpointNonExistingPolicies { payload: EndpointState['nonExistingPolicies']; } +interface ServerReturnedEndpointAgentPolicies { + type: 'serverReturnedEndpointAgentPolicies'; + payload: EndpointState['agentPolicies']; +} + interface ServerReturnedEndpointExistValue { type: 'serverReturnedEndpointExistValue'; payload: boolean; @@ -126,4 +131,5 @@ export type EndpointAction = | ServerFailedToReturnMetadataPatterns | AppRequestedEndpointList | ServerReturnedEndpointNonExistingPolicies + | ServerReturnedEndpointAgentPolicies | UserUpdatedEndpointListRefreshOptions; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index f28ae9bf55ab2..4faef85afbdc8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -52,6 +52,7 @@ describe('EndpointList store concerns', () => { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + agentPolicies: {}, endpointsExist: true, patterns: [], patternsError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 5bf085023c65d..7673702f54370 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -18,7 +18,7 @@ import { patterns, searchBarQuery, } from './selectors'; -import { EndpointState } from '../types'; +import { EndpointState, PolicyIds } from '../types'; import { sendGetEndpointSpecificPackagePolicies, sendGetEndpointSecurityPackage, @@ -105,15 +105,21 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory => { +): Promise => { if (hosts.length === 0) { return; } @@ -318,29 +336,38 @@ const getNonExistingPoliciesForEndpointsList = async ( )})`, }, }) - ).items.reduce((list, agentPolicy) => { - (agentPolicy.package_policies as string[]).forEach((packagePolicy) => { - list[packagePolicy as string] = true; - }); - return list; - }, {}); + ).items.reduce( + (list, agentPolicy) => { + (agentPolicy.package_policies as string[]).forEach((packagePolicy) => { + list.packagePolicy[packagePolicy as string] = true; + list.agentPolicy[packagePolicy as string] = agentPolicy.id; + }); + return list; + }, + { packagePolicy: {}, agentPolicy: {} } + ); - const nonExisting = policyIdsToCheck.reduce( - (list, policyId) => { - if (policiesFound[policyId]) { + // packagePolicy contains non-existing packagePolicy ids whereas agentPolicy contains existing agentPolicy ids + const nonExistingPackagePoliciesAndExistingAgentPolicies = policyIdsToCheck.reduce( + (list, policyId: string) => { + if (policiesFound.packagePolicy[policyId as string]) { + list.agentPolicy[policyId as string] = policiesFound.agentPolicy[policyId]; return list; } - list[policyId] = true; + list.packagePolicy[policyId as string] = true; return list; }, - {} + { packagePolicy: {}, agentPolicy: {} } ); - if (Object.keys(nonExisting).length === 0) { + if ( + Object.keys(nonExistingPackagePoliciesAndExistingAgentPolicies.packagePolicy).length === 0 && + Object.keys(nonExistingPackagePoliciesAndExistingAgentPolicies.agentPolicy).length === 0 + ) { return; } - return nonExisting; + return nonExistingPackagePoliciesAndExistingAgentPolicies; }; const doEndpointsExist = async (http: HttpStart): Promise => { 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 cfde474c6290d..c5363a5ae9522 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 @@ -20,6 +20,7 @@ import { } from '../../policy/store/policy_list/services/ingest'; import { GetAgentPoliciesResponse, + GetAgentPoliciesResponseItem, GetPackagesResponse, } from '../../../../../../ingest_manager/common/types/rest_spec'; import { GetPolicyListResponse } from '../../policy/types'; @@ -43,7 +44,7 @@ export const mockEndpointResultList: (options?: { // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); - const hosts = []; + const hosts: HostInfo[] = []; for (let index = 0; index < actualCountToReturn; index++) { hosts.push({ metadata: generator.generateHostMetadata(), @@ -78,12 +79,14 @@ const endpointListApiPathHandlerMocks = ({ epmPackages = [generator.generateEpmPackage()], endpointPackagePolicies = [], policyResponse = generator.generatePolicyResponse(), + agentPolicy = generator.generateAgentPolicy(), }: { /** route handlers will be setup for each individual host in this array */ endpointsResults?: HostResultList['hosts']; epmPackages?: GetPackagesResponse['response']; endpointPackagePolicies?: GetPolicyListResponse['items']; policyResponse?: HostPolicyResponse; + agentPolicy?: GetAgentPoliciesResponseItem; } = {}) => { const apiHandlers = { // endpoint package info @@ -106,7 +109,6 @@ const endpointListApiPathHandlerMocks = ({ // Do policies referenced in endpoint list exist // just returns 1 single agent policy that includes all of the packagePolicy IDs provided [INGEST_API_AGENT_POLICIES]: (): GetAgentPoliciesResponse => { - const agentPolicy = generator.generateAgentPolicy(); (agentPolicy.package_policies as string[]).push( ...endpointPackagePolicies.map((packagePolicy) => packagePolicy.id) ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index d688fa3b76b5a..99a1df7eb4002 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -30,6 +30,7 @@ export const initialEndpointListState: Immutable = { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + agentPolicies: {}, endpointsExist: true, patterns: [], patternsError: undefined, @@ -72,6 +73,14 @@ export const endpointListReducer: ImmutableReducer = ( ...action.payload, }, }; + } else if (action.type === 'serverReturnedEndpointAgentPolicies') { + return { + ...state, + agentPolicies: { + ...state.agentPolicies, + ...action.payload, + }, + }; } else if (action.type === 'serverReturnedMetadataPatterns') { // handle error case return { 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 8eefcc271794a..852bc9791fc90 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 @@ -217,6 +217,13 @@ export const nonExistingPolicies: ( state: Immutable ) => Immutable = (state) => state.nonExistingPolicies; +/** + * returns the list of known existing agent policies + */ +export const agentPolicies: ( + state: Immutable +) => Immutable = (state) => state.agentPolicies; + /** * Return boolean that indicates whether endpoints exist * @param state diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index b73e60718d12e..77f21243ea120 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -51,8 +51,10 @@ export interface EndpointState { selectedPolicyId?: string; /** Endpoint package info */ endpointPackageInfo?: GetPackagesResponse['response'][0]; - /** tracks the list of policies IDs used in Host metadata that may no longer exist */ - nonExistingPolicies: Record; + /** Tracks the list of policies IDs used in Host metadata that may no longer exist */ + nonExistingPolicies: PolicyIds['packagePolicy']; + /** List of Package Policy Ids mapped to an associated Fleet Parent Agent Policy Id*/ + agentPolicies: PolicyIds['agentPolicy']; /** Tracks whether hosts exist and helps control if onboarding should be visible */ endpointsExist: boolean; /** index patterns for query bar */ @@ -65,6 +67,15 @@ export interface EndpointState { autoRefreshInterval: number; } +/** + * packagePolicy contains a list of Package Policy IDs (received via Endpoint metadata policy response) mapped to a boolean whether they exist or not. + * agentPolicy contains a list of existing Package Policy Ids mapped to an associated Fleet parent Agent Config. + */ +export interface PolicyIds { + packagePolicy: Record; + agentPolicy: Record; +} + /** * Query params on the host page parsed from the URL */ 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 6e37367930466..14167f25d5b90 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 @@ -6,7 +6,6 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; - import { EndpointList } from './index'; import '../../../../common/mock/match_media.ts'; import { @@ -669,4 +668,98 @@ describe('when on the list page', () => { }); }); }); + + describe('when the more actions column is opened', () => { + let hostInfo: HostInfo; + let agentId: string; + let agentPolicyId: string; + const generator = new EndpointDocGenerator('seed'); + let renderAndWaitForData: () => Promise>; + + const mockEndpointListApi = () => { + const { hosts } = mockEndpointResultList(); + hostInfo = { + host_status: hosts[0].host_status, + metadata: hosts[0].metadata, + }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); + packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; + const agentPolicy = generator.generateAgentPolicy(); + agentPolicyId = agentPolicy.id; + agentId = hosts[0].metadata.elastic.agent.id; + + setEndpointListApiMockImplementation(coreStart.http, { + endpointsResults: [hostInfo], + endpointPackagePolicies: [packagePolicy], + agentPolicy, + }); + }; + + beforeEach(() => { + mockEndpointListApi(); + + reactTestingLibrary.act(() => { + history.push('/endpoints'); + }); + + renderAndWaitForData = async () => { + const renderResult = render(); + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); + return renderResult; + }; + + coreStart.application.getUrlForApp.mockImplementation((appName) => { + switch (appName) { + case 'securitySolution': + return '/app/security'; + case 'ingestManager': + return '/app/ingestManager'; + } + return appName; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to the Security Solution Host Details page', async () => { + const renderResult = await renderAndWaitForData(); + // open the endpoint actions menu + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const hostLink = await renderResult.findByTestId('hostLink'); + expect(hostLink.getAttribute('href')).toEqual( + `/app/security/hosts/${hostInfo.metadata.host.hostname}` + ); + }); + it('navigates to the Ingest Agent Policy page', async () => { + const renderResult = await renderAndWaitForData(); + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const agentPolicyLink = await renderResult.findByTestId('agentPolicyLink'); + expect(agentPolicyLink.getAttribute('href')).toEqual( + `/app/ingestManager#/policies/${agentPolicyId}` + ); + }); + it('navigates to the Ingest Agent Details page', async () => { + const renderResult = await renderAndWaitForData(); + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const agentDetailsLink = await renderResult.findByTestId('agentDetailsLink'); + expect(agentDetailsLink.getAttribute('href')).toEqual( + `/app/ingestManager#/fleet/agents/${agentId}` + ); + }); + }); }); 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 378f3cc4cb316..166f1660bf3d6 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, memo } from 'react'; +import React, { useMemo, useCallback, memo, useState } from 'react'; import { EuiHorizontalRule, EuiBasicTable, @@ -16,6 +16,11 @@ import { EuiSelectableProps, EuiSuperDatePicker, EuiSpacer, + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiContextMenuPanelProps, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; @@ -24,6 +29,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; 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 { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; @@ -42,6 +49,7 @@ import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/ import { CreatePackagePolicyRouteState, AgentPolicyDetailsDeployAgentAction, + pagePathGetters, } from '../../../../../../ingest_manager/public'; import { SecurityPageName } from '../../../../app/types'; import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; @@ -50,6 +58,8 @@ import { EndpointAction } from '../store/action'; import { EndpointPolicyLink } from './components/endpoint_policy_link'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { APP_ID } from '../../../../../common/constants'; const EndpointListNavLink = memo<{ name: string; @@ -73,9 +83,40 @@ const EndpointListNavLink = memo<{ }); EndpointListNavLink.displayName = 'EndpointListNavLink'; +const TableRowActions = memo<{ + items: EuiContextMenuPanelProps['items']; +}>(({ items }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + ); +}); +TableRowActions.displayName = 'EndpointTableRowActions'; + const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const EndpointList = () => { const history = useHistory(); + const { services } = useKibana(); const { listData, pageIndex, @@ -90,6 +131,7 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, + agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -350,8 +392,87 @@ export const EndpointList = () => { ); }, }, + { + field: '', + name: i18n.translate('xpack.securitySolution.endpoint.list.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + // eslint-disable-next-line react/display-name + render: (item: HostInfo) => { + return ( + + + , + + + , + + + , + ]} + /> + ); + }, + }, + ], + }, ]; - }, [formatUrl, queryParams, search]); + }, [formatUrl, queryParams, search, agentPolicies, services?.application?.getUrlForApp]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist) { @@ -467,3 +588,20 @@ export const EndpointList = () => { ); }; + +const EuiContextMenuItemNavByRouter = memo< + Omit & { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; + } +>(({ navigateAppId, navigateOptions, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, navigateOptions); + + return ( + + {children} + + ); +}); +EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 00b4b82f9d602..b0b8d14108004 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -28,6 +28,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], [ 'rezzani-7.example.com', @@ -38,6 +39,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.101.149.26, 2606:a000:ffc0:39:11ef:37b9:3371:578c', '6.8.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'cadmann-4.example.com', @@ -48,6 +50,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.192.213.130, 10.70.28.129', '6.6.1', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'thurlow-9.example.com', @@ -58,6 +61,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.46.229.234', '6.0.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], ]; @@ -238,6 +242,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], ['No items found'], ]; @@ -268,6 +273,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], [ 'cadmann-4.example.com', @@ -278,6 +284,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.192.213.130, 10.70.28.129', '6.6.1', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'thurlow-9.example.com', @@ -288,6 +295,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.46.229.234', '6.0.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], ]; From dcd119ce5f2935bcc5a027c45a51dfd29d1643a2 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 14 Sep 2020 18:22:28 +0200 Subject: [PATCH 12/91] [RUM Dashboard] Visitors by region map (#77135) Co-authored-by: Elastic Machine --- x-pack/plugins/apm/kibana.json | 15 +- .../plugins/apm/public/application/csmApp.tsx | 17 +- .../RumDashboard/CoreVitals/CoreVitalItem.tsx | 1 - .../app/RumDashboard/RumDashboard.tsx | 4 + .../VisitorBreakdownMap/EmbeddedMap.tsx | 183 ++++++++++++++++++ .../VisitorBreakdownMap/LayerList.ts | 174 +++++++++++++++++ .../VisitorBreakdownMap/MapToolTip.tsx | 109 +++++++++++ .../__stories__/MapTooltip.stories.tsx | 57 ++++++ .../__tests__/EmbeddedMap.test.tsx | 44 +++++ .../__tests__/LayerList.test.ts | 17 ++ .../__tests__/MapToolTip.test.tsx | 24 +++ .../__tests__/__mocks__/regions_layer.mock.ts | 151 +++++++++++++++ .../__snapshots__/EmbeddedMap.test.tsx.snap | 45 +++++ .../__snapshots__/MapToolTip.test.tsx.snap | 55 ++++++ .../VisitorBreakdownMap/index.tsx | 24 +++ .../VisitorBreakdownMap/useMapFilters.ts | 102 ++++++++++ .../components/app/RumDashboard/index.tsx | 2 +- .../app/RumDashboard/translations.ts | 12 ++ x-pack/plugins/apm/public/plugin.ts | 12 +- x-pack/plugins/maps/public/index.ts | 2 + 20 files changed, 1033 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/MapToolTip.test.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/EmbeddedMap.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/MapToolTip.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useMapFilters.ts diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 6cc3bb2a2c7e1..8aa4417580337 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -7,7 +7,8 @@ "apmOss", "data", "licensing", - "triggers_actions_ui" + "triggers_actions_ui", + "embeddable" ], "optionalPlugins": [ "cloud", @@ -22,17 +23,13 @@ ], "server": true, "ui": true, - "configPath": [ - "xpack", - "apm" - ], - "extraPublicDirs": [ - "public/style/variables" - ], + "configPath": ["xpack", "apm"], + "extraPublicDirs": ["public/style/variables"], "requiredBundles": [ "kibanaReact", "kibanaUtils", "observability", - "home" + "home", + "maps" ] } diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index cdfe42bd628cc..c63ec3700c877 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -26,7 +26,7 @@ import { LoadingIndicatorProvider } from '../context/LoadingIndicatorContext'; import { UrlParamsProvider } from '../context/UrlParamsContext'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ConfigSchema } from '../index'; -import { ApmPluginSetupDeps } from '../plugin'; +import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { px, units } from '../style/variables'; @@ -70,11 +70,13 @@ export function CsmAppRoot({ deps, history, config, + corePlugins: { embeddable }, }: { core: CoreStart; deps: ApmPluginSetupDeps; history: AppMountParameters['history']; config: ConfigSchema; + corePlugins: ApmPluginStartDeps; }) { const i18nCore = core.i18n; const plugins = deps; @@ -86,7 +88,7 @@ export function CsmAppRoot({ return ( - + @@ -110,12 +112,19 @@ export const renderApp = ( core: CoreStart, deps: ApmPluginSetupDeps, { element, history }: AppMountParameters, - config: ConfigSchema + config: ConfigSchema, + corePlugins: ApmPluginStartDeps ) => { createCallApmApi(core.http); ReactDOM.render( - , + , element ); return () => { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx index a4cbebf20b54c..22d50ca0d5c41 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx @@ -118,7 +118,6 @@ export function CoreVitalItem({ setInFocusInd(ind); }} /> - ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index f05c07e8512ac..48c0f6cc60d84 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -18,6 +18,7 @@ import { PageLoadDistribution } from './PageLoadDistribution'; import { I18LABELS } from './translations'; import { VisitorBreakdown } from './VisitorBreakdown'; import { CoreVitals } from './CoreVitals'; +import { VisitorBreakdownMap } from './VisitorBreakdownMap'; export function RumDashboard() { return ( @@ -67,6 +68,9 @@ export function RumDashboard() { + + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx new file mode 100644 index 0000000000000..93608a0ccd826 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState, useRef } from 'react'; +import uuid from 'uuid'; +import styled from 'styled-components'; + +import { + MapEmbeddable, + MapEmbeddableInput, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../maps/public/embeddable'; +import { MAP_SAVED_OBJECT_TYPE } from '../../../../../../maps/common/constants'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + ErrorEmbeddable, + ViewMode, + isErrorEmbeddable, +} from '../../../../../../../../src/plugins/embeddable/public'; +import { getLayerList } from './LayerList'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { RenderTooltipContentParams } from '../../../../../../maps/public'; +import { MapToolTip } from './MapToolTip'; +import { useMapFilters } from './useMapFilters'; +import { EmbeddableStart } from '../../../../../../../../src/plugins/embeddable/public'; + +const EmbeddedPanel = styled.div` + z-index: auto; + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + .embPanel__content { + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents + } + &&& .mapboxgl-canvas { + animation: none !important; + } +`; + +interface KibanaDeps { + embeddable: EmbeddableStart; +} +export function EmbeddedMapComponent() { + const { urlParams } = useUrlParams(); + + const { start, end, serviceName } = urlParams; + + const mapFilters = useMapFilters(); + + const [embeddable, setEmbeddable] = useState< + MapEmbeddable | ErrorEmbeddable | undefined + >(); + + const embeddableRoot: React.RefObject = useRef< + HTMLDivElement + >(null); + + const { + services: { embeddable: embeddablePlugin }, + } = useKibana(); + + if (!embeddablePlugin) { + throw new Error('Embeddable start plugin not found'); + } + const factory: any = embeddablePlugin.getEmbeddableFactory( + MAP_SAVED_OBJECT_TYPE + ); + + const input: MapEmbeddableInput = { + id: uuid.v4(), + filters: mapFilters, + refreshConfig: { + value: 0, + pause: false, + }, + viewMode: ViewMode.VIEW, + isLayerTOCOpen: false, + query: { + query: 'transaction.type : "page-load"', + language: 'kuery', + }, + ...(start && { + timeRange: { + from: new Date(start!).toISOString(), + to: new Date(end!).toISOString(), + }, + }), + hideFilterActions: true, + }; + + function renderTooltipContent({ + addFilters, + closeTooltip, + features, + isLocked, + getLayerName, + loadFeatureProperties, + loadFeatureGeometry, + }: RenderTooltipContentParams) { + const props = { + addFilters, + closeTooltip, + isLocked, + getLayerName, + loadFeatureProperties, + loadFeatureGeometry, + }; + + return ; + } + + useEffect(() => { + if (embeddable != null && serviceName) { + embeddable.updateInput({ filters: mapFilters }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapFilters]); + + // DateRange updated useEffect + useEffect(() => { + if (embeddable != null && start != null && end != null) { + const timeRange = { + from: new Date(start).toISOString(), + to: new Date(end).toISOString(), + }; + embeddable.updateInput({ timeRange }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [start, end]); + + useEffect(() => { + async function setupEmbeddable() { + if (!factory) { + throw new Error('Map embeddable not found.'); + } + const embeddableObject: any = await factory.create({ + ...input, + title: 'Visitors by region', + }); + + if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { + embeddableObject.setRenderTooltipContent(renderTooltipContent); + await embeddableObject.setLayerList(getLayerList()); + } + + setEmbeddable(embeddableObject); + } + + setupEmbeddable(); + + // we want this effect to execute exactly once after the component mounts + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // We can only render after embeddable has already initialized + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + }, [embeddable, embeddableRoot]); + + return ( + +
+ + ); +} + +EmbeddedMapComponent.displayName = 'EmbeddedMap'; + +export const EmbeddedMap = React.memo(EmbeddedMapComponent); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts new file mode 100644 index 0000000000000..138a3f4018c65 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EMSFileSourceDescriptor, + EMSTMSSourceDescriptor, + ESTermSourceDescriptor, + LayerDescriptor as BaseLayerDescriptor, + VectorLayerDescriptor as BaseVectorLayerDescriptor, + VectorStyleDescriptor, +} from '../../../../../../maps/common/descriptor_types'; +import { + AGG_TYPE, + COLOR_MAP_TYPE, + FIELD_ORIGIN, + LABEL_BORDER_SIZES, + STYLE_TYPE, + SYMBOLIZE_AS_TYPES, +} from '../../../../../../maps/common/constants'; + +import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../../../../src/plugins/apm_oss/public'; + +const ES_TERM_SOURCE: ESTermSourceDescriptor = { + type: 'ES_TERM_SOURCE', + id: '3657625d-17b0-41ef-99ba-3a2b2938655c', + indexPatternTitle: 'apm-*', + term: 'client.geo.country_iso_code', + metrics: [ + { + type: AGG_TYPE.AVG, + field: 'transaction.duration.us', + label: 'Page load duration', + }, + ], + indexPatternId: APM_STATIC_INDEX_PATTERN_ID, + applyGlobalQuery: true, +}; + +export const REGION_NAME = 'region_name'; +export const COUNTRY_NAME = 'name'; + +export const TRANSACTION_DURATION_REGION = + '__kbnjoin__avg_of_transaction.duration.us__e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41'; + +export const TRANSACTION_DURATION_COUNTRY = + '__kbnjoin__avg_of_transaction.duration.us__3657625d-17b0-41ef-99ba-3a2b2938655c'; + +interface LayerDescriptor extends BaseLayerDescriptor { + sourceDescriptor: EMSTMSSourceDescriptor; +} + +interface VectorLayerDescriptor extends BaseVectorLayerDescriptor { + sourceDescriptor: EMSFileSourceDescriptor; +} + +export function getLayerList() { + const baseLayer: LayerDescriptor = { + sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true }, + id: 'b7af286d-2580-4f47-be93-9653d594ce7e', + label: null, + minZoom: 0, + maxZoom: 24, + alpha: 1, + visible: true, + style: { type: 'TILE' }, + type: 'VECTOR_TILE', + }; + + const getLayerStyle = (fieldName: string): VectorStyleDescriptor => { + return { + type: 'VECTOR', + properties: { + icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } }, + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: fieldName, + origin: FIELD_ORIGIN.JOIN, + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: STYLE_TYPE.DYNAMIC, + options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, + iconSize: { type: STYLE_TYPE.STATIC, options: { size: 6 } }, + iconOrientation: { + type: STYLE_TYPE.STATIC, + options: { orientation: 0 }, + }, + labelText: { type: STYLE_TYPE.STATIC, options: { value: '' } }, + labelColor: { + type: STYLE_TYPE.STATIC, + options: { color: '#000000' }, + }, + labelSize: { type: STYLE_TYPE.STATIC, options: { size: 14 } }, + labelBorderColor: { + type: STYLE_TYPE.STATIC, + options: { color: '#FFFFFF' }, + }, + symbolizeAs: { options: { value: SYMBOLIZE_AS_TYPES.CIRCLE } }, + labelBorderSize: { options: { size: LABEL_BORDER_SIZES.SMALL } }, + }, + isTimeAware: true, + }; + }; + + const pageLoadDurationByCountryLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'iso2', + right: ES_TERM_SOURCE, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: 'world_countries', + tooltipProperties: [COUNTRY_NAME], + applyGlobalQuery: true, + }, + style: getLayerStyle(TRANSACTION_DURATION_COUNTRY), + id: 'e8d1d974-eed8-462f-be2c-f0004b7619b2', + label: null, + minZoom: 0, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: 'VECTOR', + }; + + const pageLoadDurationByAdminRegionLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'region_iso_code', + right: { + type: 'ES_TERM_SOURCE', + id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', + indexPatternTitle: 'apm-*', + term: 'client.geo.region_iso_code', + metrics: [{ type: AGG_TYPE.AVG, field: 'transaction.duration.us' }], + indexPatternId: APM_STATIC_INDEX_PATTERN_ID, + }, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: 'administrative_regions_lvl2', + tooltipProperties: ['region_iso_code', REGION_NAME], + }, + style: getLayerStyle(TRANSACTION_DURATION_REGION), + id: '0e936d41-8765-41c9-97f0-05e166391366', + label: null, + minZoom: 3, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: 'VECTOR', + }; + return [ + baseLayer, + pageLoadDurationByCountryLayer, + pageLoadDurationByAdminRegionLayer, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx new file mode 100644 index 0000000000000..07b40addedec3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiOutsideClickDetector, + EuiPopoverTitle, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { + COUNTRY_NAME, + REGION_NAME, + TRANSACTION_DURATION_COUNTRY, + TRANSACTION_DURATION_REGION, +} from './LayerList'; +import { RenderTooltipContentParams } from '../../../../../../maps/public'; +import { I18LABELS } from '../translations'; + +type MapToolTipProps = Partial; + +const DescriptionItem = styled(EuiDescriptionListDescription)` + &&& { + width: 25%; + } +`; + +const TitleItem = styled(EuiDescriptionListTitle)` + &&& { + width: 75%; + } +`; + +function MapToolTipComponent({ + closeTooltip, + features = [], + loadFeatureProperties, +}: MapToolTipProps) { + const { id: featureId, layerId } = features[0] ?? {}; + + const [regionName, setRegionName] = useState(featureId as string); + const [pageLoadDuration, setPageLoadDuration] = useState(''); + + const formatPageLoadValue = (val: number) => { + const valInMs = val / 1000; + if (valInMs > 1000) { + return (valInMs / 1000).toFixed(2) + ' sec'; + } + + return (valInMs / 1000).toFixed(0) + ' ms'; + }; + + useEffect(() => { + const loadRegionInfo = async () => { + if (loadFeatureProperties) { + const items = await loadFeatureProperties({ layerId, featureId }); + items.forEach((item) => { + if ( + item.getPropertyKey() === COUNTRY_NAME || + item.getPropertyKey() === REGION_NAME + ) { + setRegionName(item.getRawValue() as string); + } + if ( + item.getPropertyKey() === TRANSACTION_DURATION_REGION || + item.getPropertyKey() === TRANSACTION_DURATION_COUNTRY + ) { + setPageLoadDuration( + formatPageLoadValue(+(item.getRawValue() as string)) + ); + } + }); + } + }; + loadRegionInfo(); + }); + + return ( + { + if (closeTooltip != null) { + closeTooltip(); + } + }} + > + <> + {regionName} + + + {I18LABELS.avgPageLoadDuration} + + {pageLoadDuration} + + + + ); +} + +export const MapToolTip = React.memo(MapToolTipComponent); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx new file mode 100644 index 0000000000000..023f5d61a964e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { EuiThemeProvider } from '../../../../../../../observability/public'; +import { MapToolTip } from '../MapToolTip'; +import { COUNTRY_NAME, TRANSACTION_DURATION_COUNTRY } from '../LayerList'; + +storiesOf('app/RumDashboard/VisitorsRegionMap', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Tooltip', + () => { + const loadFeatureProps = async () => { + return [ + { + getPropertyKey: () => COUNTRY_NAME, + getRawValue: () => 'United States', + }, + { + getPropertyKey: () => TRANSACTION_DURATION_COUNTRY, + getRawValue: () => 2434353, + }, + ]; + }; + return ( + + ); + }, + { + info: { + propTables: false, + source: false, + }, + } + ); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx new file mode 100644 index 0000000000000..790be81bb65c0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { render } from 'enzyme'; +import React from 'react'; + +import { EmbeddedMap } from '../EmbeddedMap'; +import { KibanaContextProvider } from '../../../../../../../security_solution/public/common/lib/kibana'; +import { embeddablePluginMock } from '../../../../../../../../../src/plugins/embeddable/public/mocks'; + +describe('Embedded Map', () => { + test('it renders', () => { + const [core] = mockCore(); + + const wrapper = render( + + + + ); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +const mockEmbeddable = embeddablePluginMock.createStartContract(); + +mockEmbeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ + create: () => ({ + reload: jest.fn(), + setRenderTooltipContent: jest.fn(), + setLayerList: jest.fn(), + }), +})); + +const mockCore: () => [any] = () => { + const core = { + embeddable: mockEmbeddable, + }; + + return [core]; +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts new file mode 100644 index 0000000000000..eb149ee2a132d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLayerList } from './__mocks__/regions_layer.mock'; +import { getLayerList } from '../LayerList'; + +describe('LayerList', () => { + describe('getLayerList', () => { + test('it returns the region layer', () => { + const layerList = getLayerList(); + expect(layerList).toStrictEqual(mockLayerList); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/MapToolTip.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/MapToolTip.test.tsx new file mode 100644 index 0000000000000..cbaae40b04361 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/MapToolTip.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { render, shallow } from 'enzyme'; +import React from 'react'; + +import { MapToolTip } from '../MapToolTip'; + +describe('Map Tooltip', () => { + test('it shallow renders', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders', () => { + const wrapper = render(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts new file mode 100644 index 0000000000000..c45f8b27d7d3e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockLayerList = [ + { + sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true }, + id: 'b7af286d-2580-4f47-be93-9653d594ce7e', + label: null, + minZoom: 0, + maxZoom: 24, + alpha: 1, + visible: true, + style: { type: 'TILE' }, + type: 'VECTOR_TILE', + }, + { + joins: [ + { + leftField: 'iso2', + right: { + type: 'ES_TERM_SOURCE', + id: '3657625d-17b0-41ef-99ba-3a2b2938655c', + indexPatternTitle: 'apm-*', + term: 'client.geo.country_iso_code', + metrics: [ + { + type: 'avg', + field: 'transaction.duration.us', + label: 'Page load duration', + }, + ], + indexPatternId: 'apm_static_index_pattern_id', + applyGlobalQuery: true, + }, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: 'world_countries', + tooltipProperties: ['name'], + applyGlobalQuery: true, + }, + style: { + type: 'VECTOR', + properties: { + icon: { type: 'STATIC', options: { value: 'marker' } }, + fillColor: { + type: 'DYNAMIC', + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: 'ORDINAL', + field: { + name: + '__kbnjoin__avg_of_transaction.duration.us__3657625d-17b0-41ef-99ba-3a2b2938655c', + origin: 'join', + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: 'DYNAMIC', + options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: 'STATIC', options: { size: 1 } }, + iconSize: { type: 'STATIC', options: { size: 6 } }, + iconOrientation: { type: 'STATIC', options: { orientation: 0 } }, + labelText: { type: 'STATIC', options: { value: '' } }, + labelColor: { type: 'STATIC', options: { color: '#000000' } }, + labelSize: { type: 'STATIC', options: { size: 14 } }, + labelBorderColor: { type: 'STATIC', options: { color: '#FFFFFF' } }, + symbolizeAs: { options: { value: 'circle' } }, + labelBorderSize: { options: { size: 'SMALL' } }, + }, + isTimeAware: true, + }, + id: 'e8d1d974-eed8-462f-be2c-f0004b7619b2', + label: null, + minZoom: 0, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: 'VECTOR', + }, + { + joins: [ + { + leftField: 'region_iso_code', + right: { + type: 'ES_TERM_SOURCE', + id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', + indexPatternTitle: 'apm-*', + term: 'client.geo.region_iso_code', + metrics: [{ type: 'avg', field: 'transaction.duration.us' }], + indexPatternId: 'apm_static_index_pattern_id', + }, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: 'administrative_regions_lvl2', + tooltipProperties: ['region_iso_code', 'region_name'], + }, + style: { + type: 'VECTOR', + properties: { + icon: { type: 'STATIC', options: { value: 'marker' } }, + fillColor: { + type: 'DYNAMIC', + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: 'ORDINAL', + field: { + name: + '__kbnjoin__avg_of_transaction.duration.us__e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', + origin: 'join', + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: 'DYNAMIC', + options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: 'STATIC', options: { size: 1 } }, + iconSize: { type: 'STATIC', options: { size: 6 } }, + iconOrientation: { type: 'STATIC', options: { orientation: 0 } }, + labelText: { type: 'STATIC', options: { value: '' } }, + labelColor: { type: 'STATIC', options: { color: '#000000' } }, + labelSize: { type: 'STATIC', options: { size: 14 } }, + labelBorderColor: { type: 'STATIC', options: { color: '#FFFFFF' } }, + symbolizeAs: { options: { value: 'circle' } }, + labelBorderSize: { options: { size: 'SMALL' } }, + }, + isTimeAware: true, + }, + id: '0e936d41-8765-41c9-97f0-05e166391366', + label: null, + minZoom: 3, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: 'VECTOR', + }, +]; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/EmbeddedMap.test.tsx.snap b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/EmbeddedMap.test.tsx.snap new file mode 100644 index 0000000000000..67f79c9fc747e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/EmbeddedMap.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Embedded Map it renders 1`] = ` +.c0 { + z-index: auto; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + height: 100%; + position: relative; +} + +.c0 .embPanel__content { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1 1 100%; + -ms-flex: 1 1 100%; + flex: 1 1 100%; + z-index: 1; + min-height: 0; +} + +.c0.c0.c0 .mapboxgl-canvas { + -webkit-animation: none !important; + animation: none !important; +} + +
+
+
+`; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/MapToolTip.test.tsx.snap b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/MapToolTip.test.tsx.snap new file mode 100644 index 0000000000000..860727a7a0f86 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__snapshots__/MapToolTip.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Map Tooltip it renders 1`] = ` +Array [ +
, + .c1.c1.c1 { + width: 25%; +} + +.c0.c0.c0 { + width: 75%; +} + +
+
+ Average page load duration +
+
+
, +] +`; + +exports[`Map Tooltip it shallow renders 1`] = ` + + + + + Average page load duration + + + + +`; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/index.tsx new file mode 100644 index 0000000000000..44bfe5abbaca2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/index.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EmbeddedMap } from './EmbeddedMap'; +import { I18LABELS } from '../translations'; + +export function VisitorBreakdownMap() { + return ( + <> + +

{I18LABELS.pageLoadDurationByRegion}

+
+ +
+ +
+ + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useMapFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useMapFilters.ts new file mode 100644 index 0000000000000..357e04c538e68 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useMapFilters.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { FieldFilter as Filter } from '../../../../../../../../src/plugins/data/common'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + SERVICE_NAME, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, +} from '../../../../../common/elasticsearch_fieldnames'; + +import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../../../../src/plugins/apm_oss/public'; + +const getMatchFilter = (field: string, value: string): Filter => { + return { + meta: { + index: APM_STATIC_INDEX_PATTERN_ID, + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: field, + params: { query: value }, + }, + query: { match_phrase: { [field]: value } }, + }; +}; + +const getMultiMatchFilter = (field: string, values: string[]): Filter => { + return { + meta: { + index: APM_STATIC_INDEX_PATTERN_ID, + type: 'phrases', + key: field, + value: values.join(', '), + params: values, + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: values.map((value) => ({ match_phrase: { [field]: value } })), + minimum_should_match: 1, + }, + }, + }; +}; +export const useMapFilters = (): Filter[] => { + const { urlParams, uiFilters } = useUrlParams(); + + const { serviceName } = urlParams; + + const { browser, device, os, location } = uiFilters; + + const [mapFilters, setMapFilters] = useState([]); + + const existFilter: Filter = { + meta: { + index: APM_STATIC_INDEX_PATTERN_ID, + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'transaction.marks.navigationTiming.fetchStart', + value: 'exists', + }, + exists: { + field: 'transaction.marks.navigationTiming.fetchStart', + }, + }; + + useEffect(() => { + const filters = [existFilter]; + if (serviceName) { + filters.push(getMatchFilter(SERVICE_NAME, serviceName)); + } + if (browser) { + filters.push(getMultiMatchFilter(USER_AGENT_NAME, browser)); + } + if (device) { + filters.push(getMultiMatchFilter(USER_AGENT_DEVICE, device)); + } + if (os) { + filters.push(getMultiMatchFilter(USER_AGENT_OS, os)); + } + if (location) { + filters.push(getMultiMatchFilter(CLIENT_GEO_COUNTRY_ISO_CODE, location)); + } + + setMapFilters(filters); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serviceName, browser, device, os, location]); + + return mapFilters; +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index 8d1959ec14d15..fa0551252b6a1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -58,7 +58,7 @@ export function RumOverview() { return ( <> - + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index 660ed5a92a0e6..ec135168729b4 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -64,6 +64,18 @@ export const I18LABELS = { defaultMessage: 'Operating system', } ), + avgPageLoadDuration: i18n.translate( + 'xpack.apm.rum.visitorBreakdownMap.avgPageLoadDuration', + { + defaultMessage: 'Average page load duration', + } + ), + pageLoadDurationByRegion: i18n.translate( + 'xpack.apm.rum.visitorBreakdownMap.pageLoadDurationByRegion', + { + defaultMessage: 'Page load duration by region', + } + ), }; export const VisitorBreakdownLabel = i18n.translate( diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index b950b493c0f19..33e6a4b50a742 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -37,6 +37,7 @@ import { import { AlertType } from '../common/alert_types'; import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; +import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -57,6 +58,7 @@ export interface ApmPluginStartDeps { home: void; licensing: void; triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + embeddable: EmbeddableStart; } export class ApmPlugin implements Plugin { @@ -127,12 +129,18 @@ export class ApmPlugin implements Plugin { async mount(params: AppMountParameters) { // Load application bundle and Get start service - const [{ renderApp }, [coreStart]] = await Promise.all([ + const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ import('./application/csmApp'), core.getStartServices(), ]); - return renderApp(coreStart, pluginSetupDeps, params, config); + return renderApp( + coreStart, + pluginSetupDeps, + params, + config, + corePlugins as ApmPluginStartDeps + ); }, }); } diff --git a/x-pack/plugins/maps/public/index.ts b/x-pack/plugins/maps/public/index.ts index 7b5521443d974..f220f32d346e7 100644 --- a/x-pack/plugins/maps/public/index.ts +++ b/x-pack/plugins/maps/public/index.ts @@ -17,3 +17,5 @@ export const plugin: PluginInitializer = ( }; export { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; + +export { RenderTooltipContentParams } from './classes/tooltips/tooltip_property'; From 8f54c50363fc45e2d84ae990ef1fc54a4a85590a Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 14 Sep 2020 17:51:19 +0100 Subject: [PATCH 13/91] filter invalid SOs from the searc hresults in Task Manager (#76891) Filters out invalid SOs from search results to prevent a never ending loop and spamming of logs in Task Manager. --- .../task_manager/server/task_store.test.ts | 113 ++++++++++++++++-- .../plugins/task_manager/server/task_store.ts | 1 + 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index a02123c4a3f8d..45c41b4d1d69d 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -633,7 +633,7 @@ if (doc['task.runAt'].size()!=0) { const runAt = new Date(); const tasks = [ { - _id: 'aaa', + _id: 'task:aaa', _source: { type: 'task', task: { @@ -654,7 +654,104 @@ if (doc['task.runAt'].size()!=0) { sort: ['a', 1], }, { + // this is invalid as it doesn't have the `type` prefix _id: 'bbb', + _source: { + type: 'task', + task: { + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming', + params: '{ "shazm": 1 }', + state: '{ "henry": "The 8th" }', + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }, + }, + _seq_no: 3, + _primary_term: 4, + sort: ['b', 2], + }, + ]; + const { + result: { docs }, + args: { + search: { + body: { query }, + }, + }, + } = await testClaimAvailableTasks({ + opts: { + taskManagerId, + }, + claimingOpts: { + claimOwnershipUntil, + size: 10, + }, + hits: tasks, + }); + + expect(query.bool.must).toContainEqual({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it filters out invalid tasks that arent SavedObjects', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + { + _id: 'task:aaa', + _source: { + type: 'task', + task: { + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming', + params: '{ "hello": "world" }', + state: '{ "baby": "Henhen" }', + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }, + }, + _seq_no: 1, + _primary_term: 2, + sort: ['a', 1], + }, + { + _id: 'task:bbb', _source: { type: 'task', task: { @@ -729,7 +826,7 @@ if (doc['task.runAt'].size()!=0) { const runAt = new Date(); const tasks = [ { - _id: 'aaa', + _id: 'task:aaa', _source: { type: 'task', task: { @@ -750,7 +847,7 @@ if (doc['task.runAt'].size()!=0) { sort: ['a', 1], }, { - _id: 'bbb', + _id: 'task:bbb', _source: { type: 'task', task: { @@ -1069,7 +1166,7 @@ if (doc['task.runAt'].size()!=0) { const runAt = new Date(); const tasks = [ { - _id: 'claimed-by-id', + _id: 'task:claimed-by-id', _source: { type: 'task', task: { @@ -1093,7 +1190,7 @@ if (doc['task.runAt'].size()!=0) { sort: ['a', 1], }, { - _id: 'claimed-by-schedule', + _id: 'task:claimed-by-schedule', _source: { type: 'task', task: { @@ -1117,7 +1214,7 @@ if (doc['task.runAt'].size()!=0) { sort: ['b', 2], }, { - _id: 'already-running', + _id: 'task:already-running', _source: { type: 'task', task: { @@ -1378,8 +1475,8 @@ if (doc['task.runAt'].size()!=0) { }); function generateFakeTasks(count: number = 1) { - return _.times(count, () => ({ - _id: 'aaa', + return _.times(count, (index) => ({ + _id: `task:id-${index}`, _source: { type: 'task', task: {}, diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index f2da41053e6ab..acd19bd75f7a3 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -451,6 +451,7 @@ export class TaskStore { return { docs: (rawDocs as SavedObjectsRawDoc[]) + .filter((doc) => this.serializer.isRawSavedObject(doc)) .map((doc) => this.serializer.rawToSavedObject(doc)) .map((doc) => omit(doc, 'namespace') as SavedObject) .map(savedObjectToConcreteTaskInstance), From 5c457d1e820320586db98ac965278d649ff7ef7c Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 14 Sep 2020 12:56:46 -0400 Subject: [PATCH 14/91] [Security Solution][Resolver] Analyzed event styling (#77115) --- .../public/resolver/view/assets.tsx | 4 +-- .../resolver/view/process_event_dot.tsx | 26 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx index 1317c0ee94b60..a066eb9421fc1 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx @@ -477,7 +477,7 @@ export const useResolverTheme = (): { ), isLabelFilled: false, labelButtonFill: 'primary', - strokeColor: `${theme.euiColorPrimary}33`, // 33 = 20% opacity + strokeColor: theme.euiColorPrimary, }, terminatedTriggerCube: { backingFill: colorMap.triggerBackingFill, @@ -491,7 +491,7 @@ export const useResolverTheme = (): { ), isLabelFilled: false, labelButtonFill: 'danger', - strokeColor: `${theme.euiColorDanger}33`, + strokeColor: theme.euiColorDanger, }, }; diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 5d7112dd1547a..f4a7ad120e7db 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; +import { FormattedMessage } from '@kbn/i18n/react'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; @@ -119,6 +120,7 @@ const UnstyledProcessEventDot = React.memo( // Node (html id=) IDs const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); const selectedNode = useSelector(selectors.selectedNode); + const originID = useSelector(selectors.originID); const nodeID: string | undefined = eventModel.entityIDSafeVersion(event); if (nodeID === undefined) { // NB: this component should be taking nodeID as a `string` instead of handling this logic here @@ -231,6 +233,7 @@ const UnstyledProcessEventDot = React.memo( const isAriaCurrent = nodeID === ariaActiveDescendant; const isAriaSelected = nodeID === selectedNode; + const isOrigin = nodeID === originID; const dispatch = useResolverDispatch(); @@ -359,6 +362,20 @@ const UnstyledProcessEventDot = React.memo( height={markerSize * 1.5} className="backing" /> + {isOrigin && ( + + )} - {descriptionText} +
= 2 ? 'euiButton' : 'euiButton euiButton--small'} From 003fcb1332fdf7a4838286b17f4a1ea1bd556c44 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 14 Sep 2020 13:52:14 -0400 Subject: [PATCH 15/91] Add Lens to Recently Accessed (#77249) added lens to recently accessed on load and save in app.tsx --- x-pack/plugins/lens/common/constants.ts | 4 +++ .../lens/public/app_plugin/app.test.tsx | 36 +++++++++++++++++++ x-pack/plugins/lens/public/app_plugin/app.tsx | 3 ++ 3 files changed, 43 insertions(+) diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 16397d340d951..ea2331a577743 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -16,3 +16,7 @@ export function getBasePath() { export function getEditPath(id: string) { return `#/edit/${encodeURIComponent(id)}`; } + +export function getFullPath(id: string) { + return `/app/${PLUGIN_ID}${getEditPath(id)}`; +} 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 2b979f064b8eb..b70e0a4fff02e 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -300,6 +300,29 @@ describe('Lens App', () => { ]); }); + it('adds to the recently viewed list on load', async () => { + const defaultArgs = makeDefaultArgs(); + instance = mount(); + + (defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({ + id: '1234', + title: 'Daaaaaaadaumching!', + state: { + query: 'fake query', + filters: [], + }, + references: [], + }); + await act(async () => { + instance.setProps({ docId: '1234' }); + }); + expect(defaultArgs.core.chrome.recentlyAccessed.add).toHaveBeenCalledWith( + '/app/lens#/edit/1234', + 'Daaaaaaadaumching!', + '1234' + ); + }); + it('sets originatingApp breadcrumb when the document title changes', async () => { const defaultArgs = makeDefaultArgs(); defaultArgs.originatingApp = 'ultraCoolDashboard'; @@ -591,6 +614,19 @@ describe('Lens App', () => { expect(args.docStorage.load).not.toHaveBeenCalled(); }); + it('adds to the recently viewed list on save', async () => { + const { args } = await save({ + initialDocId: undefined, + newCopyOnSave: false, + newTitle: 'hello there', + }); + expect(args.core.chrome.recentlyAccessed.add).toHaveBeenCalledWith( + '/app/lens#/edit/aaa', + 'hello there', + 'aaa' + ); + }); + it('saves the latest doc as a copy', async () => { const { args, instance: inst } = await save({ initialDocId: '1234', diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 021ca8b182b2b..3f1f6d0e5509d 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -39,6 +39,7 @@ import { IndexPatternsContract, SavedQuery, } from '../../../../../src/plugins/data/public'; +import { getFullPath } from '../../common'; interface State { indicateNoData: boolean; @@ -271,6 +272,7 @@ export function App({ docStorage .load(docId) .then((doc) => { + core.chrome.recentlyAccessed.add(getFullPath(docId), doc.title, docId); getAllIndexPatterns( _.uniq( doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) @@ -365,6 +367,7 @@ export function App({ docStorage .save(doc) .then(({ id }) => { + core.chrome.recentlyAccessed.add(getFullPath(id), doc.title, id); // Prevents unnecessary network request and disables save button const newDoc = { ...doc, id }; const currentOriginatingApp = state.originatingApp; From 2055f5eecde8e540ae6c18b9007740736abb7f46 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Mon, 14 Sep 2020 14:04:33 -0400 Subject: [PATCH 16/91] [Monitoring] Handle no mappings found for sort and collapse fields (#77099) * Handle no mappings found for sort and collapse fields * Add comment * Fix sort usage * Ensure we query off MB for new api calls as well Co-authored-by: Elastic Machine --- .../monitoring/server/alerts/base_alert.ts | 14 ++++++-- .../alerts/cluster_health_alert.test.ts | 6 +++- .../server/alerts/cpu_usage_alert.test.ts | 6 +++- .../server/alerts/cpu_usage_alert.ts | 3 +- ...asticsearch_version_mismatch_alert.test.ts | 6 +++- .../kibana_version_mismatch_alert.test.ts | 6 +++- .../alerts/license_expiration_alert.test.ts | 6 +++- .../logstash_version_mismatch_alert.test.ts | 6 +++- .../server/alerts/nodes_changed_alert.test.ts | 6 +++- .../server/lib/alerts/append_mb_index.ts | 11 +++++++ .../lib/alerts/get_ccs_index_pattern.ts | 13 ++++---- .../server/lib/apm/_get_time_of_last_event.js | 1 + .../monitoring/server/lib/apm/get_apm_info.js | 2 +- .../monitoring/server/lib/apm/get_apms.js | 4 +-- .../server/lib/beats/get_beat_summary.js | 2 +- .../monitoring/server/lib/beats/get_beats.js | 4 +-- .../monitoring/server/lib/ccs_utils.js | 2 +- .../lib/cluster/flag_supported_clusters.js | 2 +- .../server/lib/cluster/get_cluster_license.js | 2 +- .../server/lib/cluster/get_clusters_state.js | 2 +- .../server/lib/cluster/get_clusters_stats.js | 2 +- .../server/lib/elasticsearch/ccr.js | 2 +- .../lib/elasticsearch/get_last_recovery.js | 2 +- .../server/lib/elasticsearch/get_ml_jobs.js | 2 +- .../indices/get_index_summary.js | 2 +- .../lib/elasticsearch/indices/get_indices.js | 2 +- .../elasticsearch/nodes/get_node_summary.js | 2 +- .../nodes/get_nodes/get_nodes.js | 2 +- .../get_indices_unassigned_shard_stats.js | 2 +- .../shards/get_nodes_shard_count.js | 2 +- .../elasticsearch/shards/get_shard_stats.js | 2 +- .../server/lib/kibana/get_kibana_info.js | 2 +- .../server/lib/kibana/get_kibanas.js | 2 +- .../server/lib/logs/get_log_types.js | 2 +- .../monitoring/server/lib/logs/get_logs.js | 2 +- .../server/lib/logstash/get_node_info.js | 2 +- .../server/lib/logstash/get_nodes.js | 2 +- .../logstash/get_pipeline_state_document.js | 2 +- .../lib/logstash/get_pipeline_versions.js | 2 +- .../monitoring/server/lib/mb_safe_query.ts | 33 +++++++++++++++++++ x-pack/plugins/monitoring/server/plugin.ts | 5 ++- .../server/routes/api/v1/elasticsearch/ccr.js | 2 +- .../routes/api/v1/elasticsearch/ccr_shard.js | 2 +- .../telemetry_collection/get_beats_stats.ts | 2 +- .../telemetry_collection/get_es_stats.ts | 2 +- .../get_high_level_stats.ts | 2 +- .../telemetry_collection/get_licenses.ts | 2 +- 47 files changed, 140 insertions(+), 52 deletions(-) create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/append_mb_index.ts create mode 100644 x-pack/plugins/monitoring/server/lib/mb_safe_query.ts diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 016acf2737f9b..f583b4882f83c 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -9,6 +9,7 @@ import { ILegacyCustomClusterClient, Logger, IUiSettingsClient, + LegacyCallAPIOptions, } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { @@ -36,6 +37,8 @@ import { MonitoringConfig } from '../config'; import { AlertSeverity } from '../../common/enums'; import { CommonAlertFilter, CommonAlertParams, CommonBaseAlert } from '../../common/types'; import { MonitoringLicenseService } from '../types'; +import { mbSafeQuery } from '../lib/mb_safe_query'; +import { appendMetricbeatIndex } from '../lib/alerts/append_mb_index'; export class BaseAlert { public type!: string; @@ -212,13 +215,20 @@ export class BaseAlert { `Executing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}` ); - const callCluster = this.monitoringCluster + const _callCluster = this.monitoringCluster ? this.monitoringCluster.callAsInternalUser : services.callCluster; + const callCluster = async ( + endpoint: string, + clientParams?: Record, + options?: LegacyCallAPIOptions + ) => { + return await mbSafeQuery(async () => _callCluster(endpoint, clientParams, options)); + }; const availableCcs = this.config.ui.ccs.enabled ? await fetchAvailableCcs(callCluster) : []; // Support CCS use cases by querying to find available remote clusters // and then adding those to the index pattern we are searching against - let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH; + let esIndexPattern = appendMetricbeatIndex(this.config, INDEX_PATTERN_ELASTICSEARCH); if (availableCcs) { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts index 4b083787f58cb..66085b53516a2 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts @@ -66,7 +66,11 @@ describe('ClusterHealthAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts index c330e977e53d8..2705a77e0fce4 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts @@ -70,7 +70,11 @@ describe('CpuUsageAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts index afe5abcf1ebd7..5bca84e33da3c 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts @@ -31,6 +31,7 @@ import { CommonAlertParams, CommonAlertParamDetail, } from '../../common/types'; +import { appendMetricbeatIndex } from '../lib/alerts/append_mb_index'; const RESOLVED = i18n.translate('xpack.monitoring.alerts.cpuUsage.resolved', { defaultMessage: 'resolved', @@ -137,7 +138,7 @@ export class CpuUsageAlert extends BaseAlert { uiSettings: IUiSettingsClient, availableCcs: string[] ): Promise { - let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH; + let esIndexPattern = appendMetricbeatIndex(this.config, INDEX_PATTERN_ELASTICSEARCH); if (availableCcs) { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } diff --git a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts index ed300c211215b..1db85f915d794 100644 --- a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts @@ -69,7 +69,11 @@ describe('ElasticsearchVersionMismatchAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts index dd3b37b5755e7..362532a995f2d 100644 --- a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts @@ -72,7 +72,11 @@ describe('KibanaVersionMismatchAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts index e2f21b34efe21..da94e4af83802 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts @@ -76,7 +76,11 @@ describe('LicenseExpirationAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts index fbb4a01d5b4ed..5ed189014cc6e 100644 --- a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts @@ -69,7 +69,11 @@ describe('LogstashVersionMismatchAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts index 4b3e3d2d6cb6d..ec2b19eb5dfae 100644 --- a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts @@ -82,7 +82,11 @@ describe('NodesChangedAlert', () => { }); const monitoringCluster = null; const config = { - ui: { ccs: { enabled: true }, container: { elasticsearch: { enabled: false } } }, + ui: { + ccs: { enabled: true }, + container: { elasticsearch: { enabled: false } }, + metricbeat: { index: 'metricbeat-*' }, + }, }; const kibanaUrl = 'http://localhost:5601'; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/append_mb_index.ts b/x-pack/plugins/monitoring/server/lib/alerts/append_mb_index.ts new file mode 100644 index 0000000000000..683a0dfeccb1f --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/alerts/append_mb_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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MonitoringConfig } from '../../config'; + +export function appendMetricbeatIndex(config: MonitoringConfig, indexPattern: string) { + return `${indexPattern},${config.ui.metricbeat.index}`; +} diff --git a/x-pack/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts b/x-pack/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts index 7fdbc79685f7c..1907d2b4b3401 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ export function getCcsIndexPattern(indexPattern: string, remotes: string[]): string { - return `${indexPattern},${indexPattern - .split(',') - .map((pattern) => { - return remotes.map((remoteName) => `${remoteName}:${pattern}`).join(','); - }) - .join(',')}`; + const patternsToAdd = []; + for (const index of indexPattern.split(',')) { + for (const remote of remotes) { + patternsToAdd.push(`${remote}:${index}`); + } + } + return [...indexPattern.split(','), ...patternsToAdd].join(','); } diff --git a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js index f08f92bffe790..37e739d0066a0 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js +++ b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js @@ -26,6 +26,7 @@ export async function getTimeOfLastEvent({ { timestamp: { order: 'desc', + unmapped_type: 'long', }, }, ], diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js index cc3682ef764c8..0b2e833933177 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js @@ -73,7 +73,7 @@ export async function getApmInfo(req, apmIndexPattern, { clusterUuid, apmUuid, s 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', ], body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ start, end, diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms.js index 19ed8298391d7..03a395e87d860 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.js @@ -128,8 +128,8 @@ export async function getApms(req, apmIndexPattern, clusterUuid) { }, }, sort: [ - { 'beats_stats.beat.uuid': { order: 'asc' } }, // need to keep duplicate uuids grouped - { timestamp: { order: 'desc' } }, // need oldest timestamp to come first for rate calcs to work + { 'beats_stats.beat.uuid': { order: 'asc', unmapped_type: 'long' } }, // need to keep duplicate uuids grouped + { timestamp: { order: 'desc', unmapped_type: 'long' } }, // need oldest timestamp to come first for rate calcs to work ], }, }; diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js index 30ec728546ce9..962018f88354d 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js @@ -78,7 +78,7 @@ export async function getBeatSummary( 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', ], body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createBeatsQuery({ start, end, diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js b/x-pack/plugins/monitoring/server/lib/beats/get_beats.js index a5d43d1da7ebc..af4b6c31a3e5e 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats.js @@ -126,8 +126,8 @@ export async function getBeats(req, beatsIndexPattern, clusterUuid) { }, }, sort: [ - { 'beats_stats.beat.uuid': { order: 'asc' } }, // need to keep duplicate uuids grouped - { timestamp: { order: 'desc' } }, // need oldest timestamp to come first for rate calcs to work + { 'beats_stats.beat.uuid': { order: 'asc', unmapped_type: 'long' } }, // need to keep duplicate uuids grouped + { timestamp: { order: 'desc', unmapped_type: 'long' } }, // need oldest timestamp to come first for rate calcs to work ], }, }; diff --git a/x-pack/plugins/monitoring/server/lib/ccs_utils.js b/x-pack/plugins/monitoring/server/lib/ccs_utils.js index bef07124fb430..96910dd86a94d 100644 --- a/x-pack/plugins/monitoring/server/lib/ccs_utils.js +++ b/x-pack/plugins/monitoring/server/lib/ccs_utils.js @@ -13,7 +13,7 @@ export function appendMetricbeatIndex(config, indexPattern) { if (isFunction(config.get)) { mbIndex = config.get('monitoring.ui.metricbeat.index'); } else { - mbIndex = get(config, 'monitoring.ui.metricbeat.index'); + mbIndex = get(config, 'ui.metricbeat.index'); } const newIndexPattern = `${indexPattern},${mbIndex}`; diff --git a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.js b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.js index 8e0d125d122aa..a1674b2f5eb36 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.js @@ -31,7 +31,7 @@ async function findSupportedBasicLicenseCluster( ignoreUnavailable: true, filterPath: 'hits.hits._source.cluster_uuid', body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: { bool: { filter: [ diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_license.js b/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_license.js index a167837969bd0..bd84fbb66f962 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_license.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_license.js @@ -18,7 +18,7 @@ export function getClusterLicense(req, esIndexPattern, clusterUuid) { ignoreUnavailable: true, filterPath: 'hits.hits._source.license', body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'cluster_stats', clusterUuid, diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.js index 33e4ec96676b2..fa5526728086e 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.js @@ -70,7 +70,7 @@ export function getClustersState(req, esIndexPattern, clusters) { collapse: { field: 'cluster_uuid', }, - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, }, }; diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.js index 945bf1f2e19a2..8ddd33837f56e 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.js @@ -67,7 +67,7 @@ function fetchClusterStats(req, esIndexPattern, clusterUuid) { collapse: { field: 'cluster_uuid', }, - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.js index 209a48cce369c..0f0ba49f229b0 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.js @@ -37,7 +37,7 @@ export async function checkCcrEnabled(req, esIndexPattern) { clusterUuid, metric: metricFields, }), - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, filterPath: ['hits.hits._source.stack_stats.xpack.ccr'], }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.js index db8c89c364463..00e750b17d57b 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.js @@ -61,7 +61,7 @@ export function getLastRecovery(req, esIndexPattern) { ignoreUnavailable: true, body: { _source: ['index_recovery.shards'], - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'index_recovery', start, end, clusterUuid, metric }), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.js index 74d4bd6d2b5df..71f3633406c9b 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.js @@ -42,7 +42,7 @@ export function getMlJobs(req, esIndexPattern) { 'hits.hits._source.job_stats.node.name', ], body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, collapse: { field: 'job_stats.job_id' }, query: createQuery({ type: 'job_stats', start, end, clusterUuid, metric }), }, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.js index 524eaca191eec..6a0935f2b2d67 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.js @@ -69,7 +69,7 @@ export function getIndexSummary( size: 1, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'index_stats', start, end, clusterUuid, metric, filters }), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.js index ba6d0cb926f06..cc3dec9f085b7 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.js @@ -129,7 +129,7 @@ export function buildGetIndicesQuery( sort: [{ timestamp: 'asc' }], }, }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, }; } diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_node_summary.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_node_summary.js index 84384021a3593..06f5d5488a1ae 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_node_summary.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_node_summary.js @@ -109,7 +109,7 @@ export function getNodeSummary( size: 1, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'node_stats', start, end, clusterUuid, metric, filters }), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js index c2794b7e7fa44..3766845d39b4f 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js @@ -92,7 +92,7 @@ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, n }, }, }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, filterPath: [ 'hits.hits._source.source_node', diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js index e8728e9c53ec5..f39233b29a1ce 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js @@ -20,7 +20,7 @@ async function getUnassignedShardData(req, esIndexPattern, cluster) { size: 0, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'shards', clusterUuid: cluster.cluster_uuid, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js index 7823884dc749d..41a4740675637 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js @@ -19,7 +19,7 @@ async function getShardCountPerNode(req, esIndexPattern, cluster) { size: 0, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'shards', clusterUuid: cluster.cluster_uuid, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js index 1154655ab6a22..2ac1e99add4de 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js @@ -57,7 +57,7 @@ export function getShardStats( size: 0, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ type: 'shards', clusterUuid: cluster.cluster_uuid, diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.js b/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.js index 533354f1e27b3..5a3e2dea930e0 100644 --- a/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.js +++ b/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.js @@ -41,7 +41,7 @@ export function getKibanaInfo(req, kbnIndexPattern, { clusterUuid, kibanaUuid }) }, }, collapse: { field: 'kibana_stats.kibana.uuid' }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, }; diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js index f0e3f961a498f..b65f7770119fc 100644 --- a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js +++ b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js @@ -44,7 +44,7 @@ export function getKibanas(req, kbnIndexPattern, { clusterUuid }) { collapse: { field: 'kibana_stats.kibana.uuid', }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], _source: [ 'timestamp', 'kibana_stats.process.memory.resident_set_size_in_bytes', diff --git a/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js b/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js index fd7b5d457409f..7947a5b6797ae 100644 --- a/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js +++ b/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js @@ -65,7 +65,7 @@ export async function getLogTypes( filterPath: ['aggregations.levels.buckets', 'aggregations.types.buckets'], ignoreUnavailable: true, body: { - sort: { '@timestamp': { order: 'desc' } }, + sort: { '@timestamp': { order: 'desc', unmapped_type: 'long' } }, query: { bool: { filter, diff --git a/x-pack/plugins/monitoring/server/lib/logs/get_logs.js b/x-pack/plugins/monitoring/server/lib/logs/get_logs.js index bb453e09454af..7952bc02b91c2 100644 --- a/x-pack/plugins/monitoring/server/lib/logs/get_logs.js +++ b/x-pack/plugins/monitoring/server/lib/logs/get_logs.js @@ -82,7 +82,7 @@ export async function getLogs( ], ignoreUnavailable: true, body: { - sort: { '@timestamp': { order: 'desc' } }, + sort: { '@timestamp': { order: 'desc', unmapped_type: 'long' } }, query: { bool: { filter, diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.js b/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.js index 929dd53f74776..fdfc523e53527 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.js @@ -46,7 +46,7 @@ export function getNodeInfo(req, lsIndexPattern, { clusterUuid, logstashUuid }) }, }, collapse: { field: 'logstash_stats.logstash.uuid' }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, }; diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js b/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js index 57adaff9be1c4..9b8786f8ae017 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js @@ -44,7 +44,7 @@ export function getNodes(req, lsIndexPattern, { clusterUuid }) { collapse: { field: 'logstash_stats.logstash.uuid', }, - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], _source: [ 'timestamp', 'logstash_stats.process.cpu.percent', diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_state_document.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_state_document.js index d844e3604ca79..dae8d52e6c57b 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_state_document.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_state_document.js @@ -37,7 +37,7 @@ export async function getPipelineStateDocument( ignoreUnavailable: true, body: { _source: { excludes: 'logstash_state.pipeline.representation.plugins' }, - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query, terminate_after: 1, // Safe to do because all these documents are functionally identical }, diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js index 91ac158b22494..c51f0f3ea1c03 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js @@ -83,7 +83,7 @@ function fetchPipelineVersions(...args) { size: 0, ignoreUnavailable: true, body: { - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query, aggs, }, diff --git a/x-pack/plugins/monitoring/server/lib/mb_safe_query.ts b/x-pack/plugins/monitoring/server/lib/mb_safe_query.ts new file mode 100644 index 0000000000000..86bf5de8601e0 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/mb_safe_query.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * This function is designed to enable us to query against `.monitoring-*` and `metricbeat-*` + * indices SAFELY. We are adding the proper aliases into `metricbeat-*` to ensure all existing + * queries/aggs continue to work but we need to handle the reality that these aliases will not + * exist for older metricbeat-* indices, created before the aliases existed. + * + * Certain parts of a query will fail in this scenario, throwing an exception because of unmapped fields. + * So far, this is known to affect `sort` and `collapse` search query parameters. We have a way + * to handle this error elegantly with `sort` but not with `collapse` so we handle it manually in this spot. + * + * We can add future edge cases in here as well. + * + * @param queryExecutor + */ +export const mbSafeQuery = async (queryExecutor: () => Promise) => { + try { + return await queryExecutor(); + } catch (err) { + if ( + err.message.includes('no mapping found for') && + err.message.includes('in order to collapse on') + ) { + return {}; + } + throw err; + } +}; diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index d874c868ae8e8..fb0bf4ac530b1 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -34,6 +34,7 @@ import { requireUIRoutes } from './routes'; import { initBulkUploader } from './kibana_monitoring'; // @ts-ignore import { initInfraSource } from './lib/logs/init_infra_source'; +import { mbSafeQuery } from './lib/mb_safe_query'; import { instantiateClient } from './es_client/instantiate_client'; import { registerCollectors } from './kibana_monitoring/collectors'; import { registerMonitoringCollection } from './telemetry_collection'; @@ -351,7 +352,9 @@ export class Plugin { callWithRequest: async (_req: any, endpoint: string, params: any) => { const client = name === 'monitoring' ? cluster : this.legacyShimDependencies.esDataClient; - return client.asScoped(req).callAsCurrentUser(endpoint, params); + return mbSafeQuery(() => + client.asScoped(req).callAsCurrentUser(endpoint, params) + ); }, }), }, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.js index 9999ba774b28d..fbaac56aa7400 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.js @@ -99,7 +99,7 @@ function buildRequest(req, config, esIndexPattern) { 'aggregations.by_follower_index.buckets.by_shard_id.buckets.follower_lag_ops.value', ], body: { - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], query: { bool: { must: [ diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.js index 4ee6cfe7fc54f..0a4b60b173254 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.js @@ -37,7 +37,7 @@ async function getCcrStat(req, esIndexPattern, filters) { 'hits.hits.inner_hits.oldest.hits.hits._source.ccr_stats.failed_read_requests', ], body: { - sort: [{ timestamp: { order: 'desc' } }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], query: { bool: { must: [ diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts index 0585ec2c08274..d153c40bbe58b 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts @@ -355,7 +355,7 @@ async function fetchBeatsByType( }), from: page * HITS_SIZE, collapse: { field: `${type}.beat.uuid` }, - sort: [{ [`${type}.timestamp`]: 'desc' }], + sort: [{ [`${type}.timestamp`]: { order: 'desc', unmapped_type: 'long' } }], size: HITS_SIZE, }, }; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts index 708bef31d8ac8..6325ed0c4b052 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts @@ -64,7 +64,7 @@ export function fetchElasticsearchStats( }, }, collapse: { field: 'cluster_uuid' }, - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, }, }; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts index 0f6a86af79e45..481afc86fd115 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts @@ -329,7 +329,7 @@ export async function fetchHighLevelStats< // a more ideal field would be the concatenation of the uuid + transport address for duped UUIDs (copied installations) field: `${product}_stats.${product}.uuid`, }, - sort: [{ timestamp: 'desc' }], + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, }; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts index 0d41ac0f46814..a8b68929e84b8 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts @@ -59,7 +59,7 @@ export function fetchLicenses( }, }, collapse: { field: 'cluster_uuid' }, - sort: { timestamp: { order: 'desc' } }, + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, }, }; From 1831fb3d2dadae43350a1ac77d085760a762120d Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Mon, 14 Sep 2020 20:27:27 +0100 Subject: [PATCH 17/91] [ML] DF Analytics creation wizard: Fixing field loading race condition (#77326) --- .../data_frame_analytics/pages/analytics_creation/page.tsx | 3 --- .../routes/data_frame_analytics/analytics_job_creation.tsx | 6 +++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx index da5caf8e3875a..e72af6a0e30c2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx @@ -21,7 +21,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useMlContext } from '../../../contexts/ml'; -import { newJobCapsService } from '../../../services/new_job_capabilities_service'; import { ml } from '../../../services/ml_api_service'; import { useCreateAnalyticsForm } from '../analytics_management/hooks/use_create_analytics_form'; import { CreateAnalyticsAdvancedEditor } from './components/create_analytics_advanced_editor'; @@ -62,8 +61,6 @@ export const Page: FC = ({ jobId }) => { if (currentIndexPattern) { (async function () { - await newJobCapsService.initializeFromIndexPattern(currentIndexPattern, false, false); - if (jobId !== undefined) { const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); if ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx index 8c45398098b2f..4ce2abf3fef60 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx @@ -16,6 +16,7 @@ import { useResolver } from '../../use_resolver'; import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_creation'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { loadNewJobCapabilities } from '../../../services/new_job_capabilities_service'; export const analyticsJobsCreationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ path: '/data_frame_analytics/new_job', @@ -36,7 +37,10 @@ const PageWrapper: FC = ({ location, deps }) => { sort: false, }); - const { context } = useResolver(index, savedSearchId, deps.config, basicResolvers(deps)); + const { context } = useResolver(index, savedSearchId, deps.config, { + ...basicResolvers(deps), + jobCaps: () => loadNewJobCapabilities(index, savedSearchId, deps.indexPatterns), + }); return ( From 61c4e6fd8d7410bfabe3d108211d7aa1d54c7ef5 Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Mon, 14 Sep 2020 15:32:30 -0400 Subject: [PATCH 18/91] Stacked headers and navigational search (#72331) Co-authored-by: Poff Poffenberger Co-authored-by: Ryan Keairns Co-authored-by: pgayvallet Co-authored-by: cchaos --- .github/CODEOWNERS | 1 + .../architecture/code-exploration.asciidoc | 598 + docs/developer/plugin-list.asciidoc | 4 + ...na-plugin-core-public.chromenavcontrols.md | 5 +- ...public.chromenavcontrols.registercenter.md | 24 + ...e-public.chromenavcontrols.registerleft.md | 2 +- ...-public.chromenavcontrols.registerright.md | 2 +- ...gin-core-public.chromestart.getnavtype_.md | 17 - .../kibana-plugin-core-public.chromestart.md | 1 - .../lib/config/schema.ts | 2 +- src/core/public/_variables.scss | 3 + .../public/application/ui/app_container.tsx | 6 +- src/core/public/chrome/chrome_service.mock.ts | 13 +- src/core/public/chrome/chrome_service.tsx | 16 +- .../nav_controls/nav_controls_service.ts | 17 +- .../collapsible_nav.test.tsx.snap | 464 +- .../header/__snapshots__/header.test.tsx.snap | 15154 ++++------------ src/core/public/chrome/ui/header/_index.scss | 9 - .../chrome/ui/header/collapsible_nav.test.tsx | 18 +- .../chrome/ui/header/collapsible_nav.tsx | 6 +- .../public/chrome/ui/header/header.test.tsx | 15 +- src/core/public/chrome/ui/header/header.tsx | 188 +- .../ui/header/header_action_menu.test.tsx | 139 + .../chrome/ui/header/header_action_menu.tsx | 64 + .../public/chrome/ui/header/header_logo.tsx | 4 +- .../chrome/ui/header/header_nav_controls.tsx | 7 +- .../public/chrome/ui/header/nav_drawer.tsx | 83 - src/core/public/index.scss | 2 +- src/core/public/public.api.md | 4 +- src/core/public/rendering/_base.scss | 70 +- src/core/public/styles/_base.scss | 11 - .../management_app/advanced_settings.tsx | 2 +- .../management_app/components/_index.scss | 1 - .../management_app/components/form/_form.scss | 15 - .../components/form/_index.scss | 1 - .../management_app/components/form/form.tsx | 19 +- .../public/management_app/index.scss | 2 - src/plugins/console/public/styles/_app.scss | 5 - .../public/application/_dashboard_app.scss | 1 - .../public/application/application.ts | 2 + .../application/dashboard_app_controller.tsx | 9 +- src/plugins/dashboard/public/plugin.tsx | 3 +- src/plugins/dev_tools/public/index.scss | 4 - src/plugins/dev_tools/public/plugin.ts | 2 +- .../public/application/angular/discover.html | 1 + .../public/application/angular/discover.js | 2 + .../discover/public/kibana_services.ts | 6 +- src/plugins/discover/public/plugin.ts | 5 +- .../public/angular/kbn_top_nav.js | 1 + src/plugins/kibana_react/public/util/index.ts | 1 + .../public/util/mount_point_portal.tsx | 23 +- src/plugins/kibana_react/public/util/utils.ts | 38 + src/plugins/management/public/plugin.ts | 2 +- .../public/top_nav_menu/_index.scss | 17 +- .../public/top_nav_menu/top_nav_menu.test.tsx | 5 +- .../public/top_nav_menu/top_nav_menu.tsx | 23 +- .../public/top_nav_menu/top_nav_menu_item.tsx | 10 +- .../components/newsfeed_header_nav_button.tsx | 2 +- src/plugins/timelion/public/_app.scss | 3 - src/plugins/timelion/public/plugin.ts | 2 +- .../components/visualize_top_nav.tsx | 3 + .../visualize/public/application/types.ts | 2 + src/plugins/visualize/public/plugin.ts | 4 +- test/accessibility/apps/management.ts | 3 +- test/functional/page_objects/time_picker.ts | 4 +- test/functional/services/listing_table.ts | 2 +- x-pack/.i18nrc.json | 6 +- x-pack/plugins/apm/public/plugin.ts | 3 +- .../__snapshots__/asset.stories.storyshot | 178 - .../components/fullscreen/fullscreen.scss | 13 +- .../plugins/canvas/public/lib/fullscreen.js | 4 + x-pack/plugins/canvas/public/plugin.tsx | 2 +- .../canvas/public/services/platform.ts | 3 + .../canvas/public/services/stubs/platform.ts | 1 + .../enterprise_search/common/constants.ts | 1 + .../enterprise_search/public/plugin.ts | 15 +- x-pack/plugins/global_search_bar/README.md | 3 + x-pack/plugins/global_search_bar/kibana.json | 10 + .../__snapshots__/search_bar.test.tsx.snap | 75 + .../public/components/search_bar.test.tsx | 97 + .../public/components/search_bar.tsx | 217 + .../plugins/global_search_bar/public/index.ts | 10 + .../global_search_bar/public/plugin.tsx | 46 + .../graph/public/angular/templates/index.html | 2 +- x-pack/plugins/graph/public/app.js | 2 + x-pack/plugins/graph/public/application.ts | 2 + .../plugins/graph/public/components/_app.scss | 2 +- x-pack/plugins/graph/public/plugin.ts | 2 +- x-pack/plugins/infra/public/plugin.ts | 4 +- .../ingest_manager/layouts/default.tsx | 10 +- .../create_package_policy_page/index.tsx | 22 +- .../components/settings/index.tsx | 29 +- .../edit_package_policy_page/index.tsx | 26 +- .../plugins/ingest_manager/public/plugin.ts | 2 +- .../lens/public/app_plugin/app.test.tsx | 3 + x-pack/plugins/lens/public/app_plugin/app.tsx | 3 + .../lens/public/app_plugin/mounter.tsx | 1 + x-pack/plugins/maps/common/constants.ts | 1 + x-pack/plugins/maps/public/_main.scss | 4 +- x-pack/plugins/maps/public/plugin.ts | 4 +- .../maps/public/routing/maps_router.tsx | 17 +- .../routes/maps_app/load_map_and_render.tsx | 2 + .../routing/routes/maps_app/maps_app_view.tsx | 2 + x-pack/plugins/ml/common/constants/app.ts | 1 + x-pack/plugins/ml/public/plugin.ts | 4 +- x-pack/plugins/observability/public/plugin.ts | 1 + .../public/application/components/main.tsx | 27 +- .../application/components/main_controls.tsx | 24 +- .../painless_lab/public/styles/_index.scss | 4 +- .../searchprofiler/public/styles/_index.scss | 4 +- .../security_solution/common/constants.ts | 1 + .../security_solution/cypress/tasks/login.ts | 4 +- .../security_solution/public/plugin.tsx | 16 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - x-pack/plugins/uptime/public/apps/plugin.ts | 2 +- .../apps/dashboard/reporting/screenshots.ts | 3 + .../functional/page_objects/graph_page.ts | 2 +- .../global_search/global_search_bar.ts | 39 + .../test_suites/global_search/index.ts | 1 + 120 files changed, 5205 insertions(+), 12930 deletions(-) create mode 100644 docs/developer/architecture/code-exploration.asciidoc create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registercenter.md delete mode 100644 docs/development/core/public/kibana-plugin-core-public.chromestart.getnavtype_.md create mode 100644 src/core/public/_variables.scss create mode 100644 src/core/public/chrome/ui/header/header_action_menu.test.tsx create mode 100644 src/core/public/chrome/ui/header/header_action_menu.tsx delete mode 100644 src/core/public/chrome/ui/header/nav_drawer.tsx delete mode 100644 src/plugins/advanced_settings/public/management_app/components/_index.scss delete mode 100644 src/plugins/advanced_settings/public/management_app/components/form/_form.scss delete mode 100644 src/plugins/advanced_settings/public/management_app/components/form/_index.scss create mode 100644 src/plugins/kibana_react/public/util/utils.ts create mode 100644 x-pack/plugins/global_search_bar/README.md create mode 100644 x-pack/plugins/global_search_bar/kibana.json create mode 100644 x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap create mode 100644 x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx create mode 100644 x-pack/plugins/global_search_bar/public/components/search_bar.tsx create mode 100644 x-pack/plugins/global_search_bar/public/index.ts create mode 100644 x-pack/plugins/global_search_bar/public/plugin.tsx create mode 100644 x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5efbaba32e00a..03a4f9520c2ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -85,6 +85,7 @@ # Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon /src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui /src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui +/x-pack/plugins/global_search_bar/ @elastic/kibana-core-ui # Observability UIs /x-pack/legacy/plugins/infra/ @elastic/logs-metrics-ui diff --git a/docs/developer/architecture/code-exploration.asciidoc b/docs/developer/architecture/code-exploration.asciidoc new file mode 100644 index 0000000000000..4a390336da34f --- /dev/null +++ b/docs/developer/architecture/code-exploration.asciidoc @@ -0,0 +1,598 @@ +//// + +NOTE: + This is an automatically generated file. Please do not edit directly. Instead, run the + following from within the kibana repository: + + node scripts/build_plugin_list_docs + + You can update the template within packages/kbn-dev-utils/target/plugin_list/generate_plugin_list.js + +//// + +[[code-exploration]] +== Exploring Kibana code + +The goals of our folder heirarchy are: + +- Easy for developers to know where to add new services, plugins and applications. +- Easy for developers to know where to find the code from services, plugins and applications. +- Easy to browse and understand our folder structure. + +To that aim, we strive to: + +- Avoid too many files in any given folder. +- Choose clear, unambigious folder names. +- Organize by domain. +- Every folder should contain a README that describes the contents of that folder. + +[discrete] +[[kibana-services-applications]] +=== Services and Applications + +[discrete] +==== src/plugins + +- {kib-repo}blob/{branch}/src/plugins/advanced_settings[advancedSettings] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/apm_oss[apmOss] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/bfetch/README.md[bfetch] + +bfetch allows to batch HTTP requests and streams responses back. + + +- {kib-repo}blob/{branch}/src/plugins/charts/README.md[charts] + +The Charts plugin is a way to create easier integration of shared colors, themes, types and other utilities across all Kibana charts and visualizations. + + +- {kib-repo}blob/{branch}/src/plugins/console[console] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/dashboard/README.md[dashboard] + +Contains the dashboard application. + + +- {kib-repo}blob/{branch}/src/plugins/data/README.md[data] + +data plugin provides common data access services. + + +- {kib-repo}blob/{branch}/src/plugins/dev_tools/README.md[devTools] + +The ui/registry/dev_tools is removed in favor of the devTools plugin which exposes a register method in the setup contract. +Registering app works mostly the same as registering apps in core.application.register. +Routing will be handled by the id of the dev tool - your dev tool will be mounted when the URL matches /app/dev_tools#/. +This API doesn't support angular, for registering angular dev tools, bootstrap a local module on mount into the given HTML element. + + +- {kib-repo}blob/{branch}/src/plugins/discover/README.md[discover] + +Contains the Discover application and the saved search embeddable. + + +- {kib-repo}blob/{branch}/src/plugins/embeddable/README.md[embeddable] + +Embeddables are re-usable widgets that can be rendered in any environment or plugin. Developers can embed them directly in their plugin. End users can dynamically add them to any embeddable containers. + + +- {kib-repo}blob/{branch}/src/plugins/es_ui_shared/README.md[esUiShared] + +This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. + + +- {kib-repo}blob/{branch}/src/plugins/expressions/README.md[expressions] + +This plugin provides methods which will parse & execute an expression pipeline +string for you, as well as a series of registries for advanced users who might +want to incorporate their own functions, types, and renderers into the service +for use in their own application. + + +- {kib-repo}blob/{branch}/src/plugins/home/README.md[home] + +Moves the legacy ui/registry/feature_catalogue module for registering "features" that should be shown in the home page's feature catalogue to a service within a "home" plugin. The feature catalogue refered to here should not be confused with the "feature" plugin for registering features used to derive UI capabilities for feature controls. + + +- {kib-repo}blob/{branch}/src/plugins/index_pattern_management[indexPatternManagement] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/input_control_vis/README.md[inputControlVis] + +Contains the input control visualization allowing to place custom filter controls on a dashboard. + + +- {kib-repo}blob/{branch}/src/plugins/inspector/README.md[inspector] + +The inspector is a contextual tool to gain insights into different elements +in Kibana, e.g. visualizations. It has the form of a flyout panel. + + +- {kib-repo}blob/{branch}/src/plugins/kibana_legacy/README.md[kibanaLegacy] + +This plugin will contain several helpers and services to integrate pieces of the legacy Kibana app with the new Kibana platform. + + +- {kib-repo}blob/{branch}/src/plugins/kibana_react/README.md[kibanaReact] + +Tools for building React applications in Kibana. + + +- {kib-repo}blob/{branch}/src/plugins/kibana_usage_collection/README.md[kibanaUsageCollection] + +This plugin registers the basic usage collectors from Kibana: + + +- {kib-repo}blob/{branch}/src/plugins/kibana_utils/README.md[kibanaUtils] + +Utilities for building Kibana plugins. + + +- {kib-repo}blob/{branch}/src/plugins/legacy_export[legacyExport] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/management[management] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/maps_legacy[mapsLegacy] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/navigation/README.md[navigation] + +The navigation plugins exports the TopNavMenu component. +It also provides a stateful version of it on the start contract. + + +- {kib-repo}blob/{branch}/src/plugins/newsfeed[newsfeed] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/region_map[regionMap] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/saved_objects[savedObjects] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/saved_objects_management[savedObjectsManagement] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/share/README.md[share] + +Replaces the legacy ui/share module for registering share context menus. + + +- {kib-repo}blob/{branch}/src/plugins/telemetry/README.md[telemetry] + +Telemetry allows Kibana features to have usage tracked in the wild. The general term "telemetry" refers to multiple things: + + +- {kib-repo}blob/{branch}/src/plugins/telemetry_collection_manager/README.md[telemetryCollectionManager] + +Telemetry's collection manager to go through all the telemetry sources when fetching it before reporting. + + +- {kib-repo}blob/{branch}/src/plugins/telemetry_management_section/README.md[telemetryManagementSection] + +This plugin adds the Advanced Settings section for the Usage Data collection (aka Telemetry). + + +- {kib-repo}blob/{branch}/src/plugins/tile_map[tileMap] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/src/plugins/timelion/README.md[timelion] + +Contains the deprecated timelion application. For the timelion visualization, +which also contains the timelion APIs and backend, look at the vis_type_timelion plugin. + + +- {kib-repo}blob/{branch}/src/plugins/ui_actions/README.md[uiActions] + +An API for: + + +- {kib-repo}blob/{branch}/src/plugins/usage_collection/README.md[usageCollection] + +Usage Collection allows collecting usage data for other services to consume (telemetry and monitoring). +To integrate with the telemetry services for usage collection of your feature, there are 2 steps: + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_markdown/README.md[visTypeMarkdown] + +The markdown visualization that can be used to place text panels on dashboards. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_metric/README.md[visTypeMetric] + +Contains the metric visualization. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_table/README.md[visTypeTable] + +Contains the data table visualization, that allows presenting data in a simple table format. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_tagcloud/README.md[visTypeTagcloud] + +Contains the tagcloud visualization. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_timelion/README.md[visTypeTimelion] + +Contains the timelion visualization and the timelion backend. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_timeseries/README.md[visTypeTimeseries] + +Contains everything around TSVB (the editor, visualizatin implementations and backends). + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_vega/README.md[visTypeVega] + +Contains the Vega visualization. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_vislib/README.md[visTypeVislib] + +Contains the vislib visualizations. These are the classical area/line/bar, pie, gauge/goal and +heatmap charts. + + +- {kib-repo}blob/{branch}/src/plugins/vis_type_xy/README.md[visTypeXy] + +Contains the new xy-axis chart using the elastic-charts library, which will eventually +replace the vislib xy-axis (bar, area, line) charts. + + +- {kib-repo}blob/{branch}/src/plugins/visualizations/README.md[visualizations] + +Contains most of the visualization infrastructure, e.g. the visualization type registry or the +visualization embeddable. + + +- {kib-repo}blob/{branch}/src/plugins/visualize/README.md[visualize] + +Contains the visualize application which includes the listing page and the app frame, +which will load the visualization's editor. + + +[discrete] +==== x-pack/plugins + +- {kib-repo}blob/{branch}/x-pack/plugins/actions/README.md[actions] + +The Kibana actions plugin provides a framework to create executable actions. You can: + + +- {kib-repo}blob/{branch}/x-pack/plugins/alerting_builtins/README.md[alertingBuiltins] + +This plugin provides alertTypes shipped with Kibana for use with the +the alerts plugin. When enabled, it will register +the built-in alertTypes with the alerting plugin, register associated HTTP +routes, etc. + + +- {kib-repo}blob/{branch}/x-pack/plugins/alerts/README.md[alerts] + +The Kibana alerting plugin provides a common place to set up alerts. You can: + + +- {kib-repo}blob/{branch}/x-pack/plugins/apm/readme.md[apm] + +To access an elasticsearch instance that has live data you have two options: + + +- {kib-repo}blob/{branch}/x-pack/plugins/audit_trail[auditTrail] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] + +Notes: +Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place + + +- {kib-repo}blob/{branch}/x-pack/plugins/canvas/README.md[canvas] + +"Never look back. The past is done. The future is a blank canvas." ― Suzy Kassem, Rise Up and Salute the Sun + + +- {kib-repo}blob/{branch}/x-pack/plugins/case/README.md[case] + +Experimental Feature + + +- {kib-repo}blob/{branch}/x-pack/plugins/cloud[cloud] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/code[code] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/console_extensions[consoleExtensions] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/cross_cluster_replication/README.md[crossClusterReplication] + +You can run a local cluster and simulate a remote cluster within a single Kibana directory. + + +- {kib-repo}blob/{branch}/x-pack/plugins/dashboard_enhanced/README.md[dashboardEnhanced] + +Contains the enhancements to the OSS dashboard app. + + +- {kib-repo}blob/{branch}/x-pack/plugins/dashboard_mode/README.md[dashboardMode] + +The deprecated dashboard only mode. + + +- {kib-repo}blob/{branch}/x-pack/plugins/data_enhanced[dataEnhanced] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/discover_enhanced/README.md[discoverEnhanced] + +Contains the enhancements to the OSS discover app. + + +- {kib-repo}blob/{branch}/x-pack/plugins/embeddable_enhanced[embeddableEnhanced] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/encrypted_saved_objects/README.md[encryptedSavedObjects] + +The purpose of this plugin is to provide a way to encrypt/decrypt attributes on the custom Saved Objects that works with +security and spaces filtering as well as performing audit logging. + + +- {kib-repo}blob/{branch}/x-pack/plugins/enterprise_search/README.md[enterpriseSearch] + +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness: + + +- {kib-repo}blob/{branch}/x-pack/plugins/event_log/README.md[eventLog] + +The purpose of this plugin is to provide a way to persist a history of events +occuring in Kibana, initially just for the Make It Action project - alerts +and actions. + + +- {kib-repo}blob/{branch}/x-pack/plugins/features[features] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/file_upload[fileUpload] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/global_search/README.md[globalSearch] + +The GlobalSearch plugin provides an easy way to search for various objects, such as applications +or dashboards from the Kibana instance, from both server and client-side plugins + + +- {kib-repo}blob/{branch}/x-pack/plugins/global_search_bar/README.md[globalSearchBar] + +The GlobalSearchBar plugin provides a search interface for navigating Kibana. (It is the UI to the GlobalSearch plugin.) + + +- {kib-repo}blob/{branch}/x-pack/plugins/global_search_providers[globalSearchProviders] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/graph/README.md[graph] + +This is the main source folder of the Graph plugin. It contains all of the Kibana server and client source code. x-pack/test/functional/apps/graph contains additional functional tests. + + +- {kib-repo}blob/{branch}/x-pack/plugins/grokdebugger/README.md[grokdebugger] + +- {kib-repo}blob/{branch}/x-pack/plugins/index_lifecycle_management/README.md[indexLifecycleManagement] + +You can test that the Frozen badge, phase filtering, and lifecycle information is surfaced in +Index Management by running this series of requests in Console: + + +- {kib-repo}blob/{branch}/x-pack/plugins/index_management[indexManagement] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/infra/README.md[infra] + +This is the home of the infra plugin, which aims to provide a solution for +the infrastructure monitoring use-case within Kibana. + + +- {kib-repo}blob/{branch}/x-pack/plugins/ingest_manager/README.md[ingestManager] + +Fleet needs to have Elasticsearch API keys enabled, and also to have TLS enabled on kibana, (if you want to run Kibana without TLS you can provide the following config flag --xpack.ingestManager.fleet.tlsCheckDisabled=false) + + +- {kib-repo}blob/{branch}/x-pack/plugins/ingest_pipelines/README.md[ingestPipelines] + +The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest nodes. Please refer to the Elasticsearch documentation for more details. + + +- {kib-repo}blob/{branch}/x-pack/plugins/lens/readme.md[lens] + +Run all tests from the x-pack root directory + + +- {kib-repo}blob/{branch}/x-pack/plugins/license_management[licenseManagement] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/licensing/README.md[licensing] + +The licensing plugin retrieves license data from Elasticsearch at regular configurable intervals. + + +- {kib-repo}blob/{branch}/x-pack/plugins/lists/README.md[lists] + +README.md for developers working on the backend lists on how to get started +using the CURL scripts in the scripts folder. + + +- {kib-repo}blob/{branch}/x-pack/plugins/logstash[logstash] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/maps/README.md[maps] + +Visualize geo data from Elasticsearch or 3rd party geo-services. + + +- {kib-repo}blob/{branch}/x-pack/plugins/maps_legacy_licensing/README.md[mapsLegacyLicensing] + +This plugin provides access to the detailed tile map services from Elastic. + + +- {kib-repo}blob/{branch}/x-pack/plugins/ml[ml] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/monitoring[monitoring] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/observability/README.md[observability] + +This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. + + +- {kib-repo}blob/{branch}/x-pack/plugins/oss_telemetry[ossTelemetry] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/painless_lab[painlessLab] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/remote_clusters[remoteClusters] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/reporting/README.md[reporting] + +An awesome Kibana reporting plugin + + +- {kib-repo}blob/{branch}/x-pack/plugins/rollup/README.md[rollup] + +Welcome to the Kibana rollup plugin! This plugin provides Kibana support for Elasticsearch's rollup feature. Please refer to the Elasticsearch documentation to understand rollup indices and how to create rollup jobs. + + +- {kib-repo}blob/{branch}/x-pack/plugins/searchprofiler[searchprofiler] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/security/README.md[security] + +See Configuring security in Kibana. + + +- {kib-repo}blob/{branch}/x-pack/plugins/security_solution/README.md[securitySolution] + +Welcome to the Kibana Security Solution plugin! This README will go over getting started with development and testing. + + +- {kib-repo}blob/{branch}/x-pack/plugins/snapshot_restore/README.md[snapshotRestore] + +or + + +- {kib-repo}blob/{branch}/x-pack/plugins/spaces[spaces] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/task_manager[taskManager] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/telemetry_collection_xpack/README.md[telemetryCollectionXpack] + +Gathers all usage collection, retrieving them from both: OSS and X-Pack plugins. + + +- {kib-repo}blob/{branch}/x-pack/plugins/transform[transform] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/translations[translations] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/triggers_actions_ui/README.md[triggers_actions_ui] + +The Kibana alerts and actions UI plugin provides a user interface for managing alerts and actions. +As a developer you can reuse and extend built-in alerts and actions UI functionality: + + +- {kib-repo}blob/{branch}/x-pack/plugins/ui_actions_enhanced/README.md[uiActionsEnhanced] + +- {kib-repo}blob/{branch}/x-pack/plugins/upgrade_assistant[upgradeAssistant] + +WARNING: Missing README. + + +- {kib-repo}blob/{branch}/x-pack/plugins/uptime/README.md[uptime] + +The purpose of this plugin is to provide users of Heartbeat more visibility of what's happening +in their infrastructure. + + +- {kib-repo}blob/{branch}/x-pack/plugins/watcher/README.md[watcher] + +This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): + diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 275fdf8fb69ad..7727cd322181f 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -341,6 +341,10 @@ and actions. or dashboards from the Kibana instance, from both server and client-side plugins +|{kib-repo}blob/{branch}/x-pack/plugins/global_search_bar/README.md[globalSearchBar] +|The GlobalSearchBar plugin provides a search interface for navigating Kibana. (It is the UI to the GlobalSearch plugin.) + + |{kib-repo}blob/{branch}/x-pack/plugins/global_search_providers[globalSearchProviders] |WARNING: Missing README. diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md index bca69adeef66b..47365782599ed 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md @@ -30,6 +30,7 @@ chrome.navControls.registerLeft({ | Method | Description | | --- | --- | -| [registerLeft(navControl)](./kibana-plugin-core-public.chromenavcontrols.registerleft.md) | Register a nav control to be presented on the left side of the chrome header. | -| [registerRight(navControl)](./kibana-plugin-core-public.chromenavcontrols.registerright.md) | Register a nav control to be presented on the right side of the chrome header. | +| [registerCenter(navControl)](./kibana-plugin-core-public.chromenavcontrols.registercenter.md) | Register a nav control to be presented on the top-center side of the chrome header. | +| [registerLeft(navControl)](./kibana-plugin-core-public.chromenavcontrols.registerleft.md) | Register a nav control to be presented on the bottom-left side of the chrome header. | +| [registerRight(navControl)](./kibana-plugin-core-public.chromenavcontrols.registerright.md) | Register a nav control to be presented on the top-right side of the chrome header. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registercenter.md b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registercenter.md new file mode 100644 index 0000000000000..2f921050e58dd --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registercenter.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeNavControls](./kibana-plugin-core-public.chromenavcontrols.md) > [registerCenter](./kibana-plugin-core-public.chromenavcontrols.registercenter.md) + +## ChromeNavControls.registerCenter() method + +Register a nav control to be presented on the top-center side of the chrome header. + +Signature: + +```typescript +registerCenter(navControl: ChromeNavControl): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| navControl | ChromeNavControl | | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerleft.md b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerleft.md index c5c78bf9fb1da..514c44bd9d710 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerleft.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerleft.md @@ -4,7 +4,7 @@ ## ChromeNavControls.registerLeft() method -Register a nav control to be presented on the left side of the chrome header. +Register a nav control to be presented on the bottom-left side of the chrome header. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerright.md b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerright.md index 12058f1d16ab9..eb56e0e38c6c9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerright.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.registerright.md @@ -4,7 +4,7 @@ ## ChromeNavControls.registerRight() method -Register a nav control to be presented on the right side of the chrome header. +Register a nav control to be presented on the top-right side of the chrome header. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.getnavtype_.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.getnavtype_.md deleted file mode 100644 index 09864be43996d..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.getnavtype_.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [getNavType$](./kibana-plugin-core-public.chromestart.getnavtype_.md) - -## ChromeStart.getNavType$() method - -Get the navigation type TODO \#64541 Can delete - -Signature: - -```typescript -getNavType$(): Observable; -``` -Returns: - -`Observable` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index e983ad50d2afe..2594848ef0847 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -59,7 +59,6 @@ core.chrome.setHelpExtension(elem => { | [getHelpExtension$()](./kibana-plugin-core-public.chromestart.gethelpextension_.md) | Get an observable of the current custom help conttent | | [getIsNavDrawerLocked$()](./kibana-plugin-core-public.chromestart.getisnavdrawerlocked_.md) | Get an observable of the current locked state of the nav drawer. | | [getIsVisible$()](./kibana-plugin-core-public.chromestart.getisvisible_.md) | Get an observable of the current visibility state of the chrome. | -| [getNavType$()](./kibana-plugin-core-public.chromestart.getnavtype_.md) | Get the navigation type TODO \#64541 Can delete | | [removeApplicationClass(className)](./kibana-plugin-core-public.chromestart.removeapplicationclass.md) | Remove a className added with addApplicationClass(). If className is unknown it is ignored. | | [setAppTitle(appTitle)](./kibana-plugin-core-public.chromestart.setapptitle.md) | Sets the current app's title | | [setBadge(badge)](./kibana-plugin-core-public.chromestart.setbadge.md) | Override the current badge | diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index e1d3bf1a8d901..701171876ad2c 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -262,7 +262,7 @@ export const schema = Joi.object() // settings for the find service layout: Joi.object() .keys({ - fixedHeaderHeight: Joi.number().default(50), + fixedHeaderHeight: Joi.number().default(100), }) .default(), diff --git a/src/core/public/_variables.scss b/src/core/public/_variables.scss new file mode 100644 index 0000000000000..8c054e770bd4b --- /dev/null +++ b/src/core/public/_variables.scss @@ -0,0 +1,3 @@ +@import '@elastic/eui/src/global_styling/variables/header'; + +$kbnHeaderOffset: $euiHeaderHeightCompensation * 2; diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index f668cf851da55..089d1cf3f3ced 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -94,8 +94,10 @@ export const AppContainer: FunctionComponent = ({ // eslint-disable-next-line no-console console.error(e); } finally { - setShowSpinner(false); - setIsMounting(false); + if (elementRef.current) { + setShowSpinner(false); + setIsMounting(false); + } } }; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 5862ee7175f71..0ae8b132f1d86 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -17,14 +17,7 @@ * under the License. */ import { BehaviorSubject } from 'rxjs'; -import { - ChromeBadge, - ChromeBrand, - ChromeBreadcrumb, - ChromeService, - InternalChromeStart, - NavType, -} from './'; +import { ChromeBadge, ChromeBrand, ChromeBreadcrumb, ChromeService, InternalChromeStart } from './'; const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { @@ -50,8 +43,10 @@ const createStartContractMock = () => { }, navControls: { registerLeft: jest.fn(), + registerCenter: jest.fn(), registerRight: jest.fn(), getLeft$: jest.fn(), + getCenter$: jest.fn(), getRight$: jest.fn(), }, setAppTitle: jest.fn(), @@ -70,7 +65,6 @@ const createStartContractMock = () => { setHelpExtension: jest.fn(), setHelpSupportUrl: jest.fn(), getIsNavDrawerLocked$: jest.fn(), - getNavType$: jest.fn(), getCustomNavLink$: jest.fn(), setCustomNavLink: jest.fn(), }; @@ -83,7 +77,6 @@ const createStartContractMock = () => { startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); - startContract.getNavType$.mockReturnValue(new BehaviorSubject('modern' as NavType)); return startContract; }; diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index b96c34cd9fbe8..b01f120b81305 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -37,7 +37,6 @@ import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { NavType } from './ui/header'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; @@ -172,10 +171,6 @@ export class ChromeService { const getIsNavDrawerLocked$ = isNavDrawerLocked$.pipe(takeUntil(this.stop$)); - // TODO #64541 - // Can delete - const getNavType$ = uiSettings.get$('pageNavigation').pipe(takeUntil(this.stop$)); - const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -241,10 +236,10 @@ export class ChromeService { navLinks$={navLinks.getNavLinks$()} recentlyAccessed$={recentlyAccessed.get$()} navControlsLeft$={navControls.getLeft$()} + navControlsCenter$={navControls.getCenter$()} navControlsRight$={navControls.getRight$()} onIsLockedUpdate={setIsNavDrawerLocked} isLocked$={getIsNavDrawerLocked$} - navType$={getNavType$} /> ), @@ -305,8 +300,6 @@ export class ChromeService { getIsNavDrawerLocked$: () => getIsNavDrawerLocked$, - getNavType$: () => getNavType$, - getCustomNavLink$: () => customNavLink$.pipe(takeUntil(this.stop$)), setCustomNavLink: (customNavLink?: ChromeNavLink) => { @@ -468,13 +461,6 @@ export interface ChromeStart { * Get an observable of the current locked state of the nav drawer. */ getIsNavDrawerLocked$(): Observable; - - /** - * Get the navigation type - * TODO #64541 - * Can delete - */ - getNavType$(): Observable; } /** @internal */ diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.ts b/src/core/public/chrome/nav_controls/nav_controls_service.ts index 167948e01cb36..2638f40c77dc4 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.ts @@ -45,14 +45,18 @@ export interface ChromeNavControl { * @public */ export interface ChromeNavControls { - /** Register a nav control to be presented on the left side of the chrome header. */ + /** Register a nav control to be presented on the bottom-left side of the chrome header. */ registerLeft(navControl: ChromeNavControl): void; - /** Register a nav control to be presented on the right side of the chrome header. */ + /** Register a nav control to be presented on the top-right side of the chrome header. */ registerRight(navControl: ChromeNavControl): void; + /** Register a nav control to be presented on the top-center side of the chrome header. */ + registerCenter(navControl: ChromeNavControl): void; /** @internal */ getLeft$(): Observable; /** @internal */ getRight$(): Observable; + /** @internal */ + getCenter$(): Observable; } /** @internal */ @@ -62,6 +66,7 @@ export class NavControlsService { public start() { const navControlsLeft$ = new BehaviorSubject>(new Set()); const navControlsRight$ = new BehaviorSubject>(new Set()); + const navControlsCenter$ = new BehaviorSubject>(new Set()); return { // In the future, registration should be moved to the setup phase. This @@ -72,6 +77,9 @@ export class NavControlsService { registerRight: (navControl: ChromeNavControl) => navControlsRight$.next(new Set([...navControlsRight$.value.values(), navControl])), + registerCenter: (navControl: ChromeNavControl) => + navControlsCenter$.next(new Set([...navControlsCenter$.value.values(), navControl])), + getLeft$: () => navControlsLeft$.pipe( map((controls) => sortBy([...controls.values()], 'order')), @@ -82,6 +90,11 @@ export class NavControlsService { map((controls) => sortBy([...controls.values()], 'order')), takeUntil(this.stop$) ), + getCenter$: () => + navControlsCenter$.pipe( + map((controls) => sortBy([...controls.values()], 'order')), + takeUntil(this.stop$) + ), }; } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index a770ece8496e4..86cacfe98f767 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -121,7 +121,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` homeHref="/" id="collapsibe-nav" isLocked={false} - isOpen={true} + isNavOpen={true} navLinks$={ BehaviorSubject { "_isScalar": false, @@ -2105,7 +2105,7 @@ exports[`CollapsibleNav renders the default nav 1`] = ` homeHref="/" id="collapsibe-nav" isLocked={false} - isOpen={false} + isNavOpen={false} navLinks$={ BehaviorSubject { "_isScalar": false, @@ -2339,6 +2339,7 @@ exports[`CollapsibleNav renders the default nav 2`] = ` homeHref="/" id="collapsibe-nav" isLocked={false} + isNavOpen={false} isOpen={true} navLinks$={ BehaviorSubject { @@ -2454,461 +2455,9 @@ exports[`CollapsibleNav renders the default nav 2`] = ` data-test-subj="collapsibleNav" id="collapsibe-nav" isDocked={false} - isOpen={true} + isOpen={false} onClose={[Function]} - > - - - -
- -
-
- + /> `; @@ -3025,6 +2574,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` homeHref="/" id="collapsibe-nav" isLocked={true} + isNavOpen={false} isOpen={true} navLinks$={ BehaviorSubject { @@ -3140,7 +2690,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` data-test-subj="collapsibleNav" id="collapsibe-nav" isDocked={true} - isOpen={true} + isOpen={false} onClose={[Function]} > - -
-
-
- - -`; - -exports[`Header renders 2`] = ` -
+ -
- -
- -
- -
- - - -
-
- -
+ - - - -
- - - - -
- - , + ], + }, + Object { + "borders": "none", + "items": Array [ + -
- - - - - - - - - , ], - "thrownError": null, - } - } - /> - -
- -
+ }, + Object { + "borders": "none", + "items": Array [ , + , + ], + }, + ] + } + theme="dark" + > +
+ +
+ + + + +
+ +
+ +
+
+
+
+ +
+ +
+ - - - - } - closePopover={[Function]} - data-test-subj="helpMenuButton" - display="inlineBlock" - hasArrow={true} - id="headerHelpMenu" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - repositionOnScroll={true} - > - -
-
- - - -
-
-
-
- -
-
-
- -
-
-
- - - - -
-
-`; - -exports[`Header renders 3`] = ` -
- -
-
-
- -
- -
- -
- -
- - - -
-
- -
- - - - -
- - - - -
- - -
- - - - - - - - - - -
- -
- - - - - - } - closePopover={[Function]} - data-test-subj="helpMenuButton" - display="inlineBlock" - hasArrow={true} - id="headerHelpMenu" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - repositionOnScroll={true} - > - -
-
- - - -
-
-
-
-
-
-
-
- -
-
-
- - - - - -
- -
-
-
-
-
-
-`; - -exports[`Header renders 4`] = ` -
- -
-
-
- -
- -
- -
- - -
- - - -
-
-
- -
- - - - -
- - - - -
- - -
- - - - - - - - - - -
- -
- - + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
+
+ + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + +
+ +
+ +
+ + + +
+
+ +
+ + +
+ + + + + + + + + + +
+ +
+ - - - - } - closePopover={[Function]} - data-test-subj="helpMenuButton" - display="inlineBlock" - hasArrow={true} - id="headerHelpMenu" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - repositionOnScroll={true} - > - -
-
- - - -
-
-
-
- - -
-
- -
-
-
- - + +
+ +
+ +
+ +
+ - - + - - - - + close + + + + + + + + +
+ + + `; diff --git a/src/core/public/chrome/ui/header/_index.scss b/src/core/public/chrome/ui/header/_index.scss index e3b73abbcabc2..44cd864278325 100644 --- a/src/core/public/chrome/ui/header/_index.scss +++ b/src/core/public/chrome/ui/header/_index.scss @@ -1,14 +1,5 @@ @include euiHeaderAffordForFixed; -// TODO #64541 -// Delete this block -.chrHeaderWrapper:not(.headerWrapper) { - width: 100%; - position: fixed; - top: 0; - z-index: 10; -} - .chrHeaderHelpMenu__version { text-transform: none; } diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index d4325e0caf88c..e33e76a45580e 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -59,7 +59,7 @@ function mockProps() { basePath: httpServiceMock.createSetupContract({ basePath: '/test' }).basePath, id: 'collapsibe-nav', isLocked: false, - isOpen: false, + isNavOpen: false, homeHref: '/', navLinks$: new BehaviorSubject([]), recentlyAccessed$: new BehaviorSubject([]), @@ -123,7 +123,7 @@ describe('CollapsibleNav', () => { const component = mount( { const component = mount( @@ -147,9 +147,9 @@ describe('CollapsibleNav', () => { clickGroup(component, 'kibana'); clickGroup(component, 'recentlyViewed'); expectShownNavLinksCount(component, 1); - component.setProps({ isOpen: false }); + component.setProps({ isNavOpen: false }); expectNavIsClosed(component); - component.setProps({ isOpen: true }); + component.setProps({ isNavOpen: true }); expectShownNavLinksCount(component, 1); }); @@ -160,14 +160,14 @@ describe('CollapsibleNav', () => { const component = mount( ); component.setProps({ closeNav: () => { - component.setProps({ isOpen: false }); + component.setProps({ isNavOpen: false }); onClose(); }, }); @@ -175,11 +175,11 @@ describe('CollapsibleNav', () => { component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); - component.setProps({ isOpen: true }); + component.setProps({ isNavOpen: true }); component.find('[data-test-subj="collapsibleNavGroup-kibana"] a').simulate('click'); expect(onClose.callCount).toEqual(2); expectNavIsClosed(component); - component.setProps({ isOpen: true }); + component.setProps({ isNavOpen: true }); component.find('[data-test-subj="collapsibleNavGroup-noCategory"] a').simulate('click'); expect(onClose.callCount).toEqual(3); expectNavIsClosed(component); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index a5f42c0949562..01cdb9c38881a 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -79,7 +79,7 @@ interface Props { basePath: HttpStart['basePath']; id: string; isLocked: boolean; - isOpen: boolean; + isNavOpen: boolean; homeHref: string; navLinks$: Rx.Observable; recentlyAccessed$: Rx.Observable; @@ -94,7 +94,7 @@ export function CollapsibleNav({ basePath, id, isLocked, - isOpen, + isNavOpen, homeHref, storage = window.localStorage, onIsLockedUpdate, @@ -129,7 +129,7 @@ export function CollapsibleNav({ aria-label={i18n.translate('core.ui.primaryNav.screenReaderLabel', { defaultMessage: 'Primary', })} - isOpen={isOpen} + isOpen={isNavOpen} isDocked={isLocked} onClose={closeNav} > diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 04eb256f30f37..7309b9af49388 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -21,7 +21,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { BehaviorSubject } from 'rxjs'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { NavType } from '.'; import { httpServiceMock } from '../../../http/http_service.mock'; import { applicationServiceMock } from '../../../mocks'; import { Header } from './header'; @@ -51,10 +50,10 @@ function mockProps() { helpExtension$: new BehaviorSubject(undefined), helpSupportUrl$: new BehaviorSubject(''), navControlsLeft$: new BehaviorSubject([]), + navControlsCenter$: new BehaviorSubject([]), navControlsRight$: new BehaviorSubject([]), basePath: http.basePath, isLocked$: new BehaviorSubject(false), - navType$: new BehaviorSubject('modern' as NavType), loadingCount$: new BehaviorSubject(0), onIsLockedUpdate: () => {}, }; @@ -71,7 +70,6 @@ describe('Header', () => { const isVisible$ = new BehaviorSubject(false); const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }]); const isLocked$ = new BehaviorSubject(false); - const navType$ = new BehaviorSubject('modern' as NavType); const navLinks$ = new BehaviorSubject([ { id: 'kibana', title: 'kibana', baseUrl: '', href: '' }, ]); @@ -92,22 +90,19 @@ describe('Header', () => { navLinks$={navLinks$} recentlyAccessed$={recentlyAccessed$} isLocked$={isLocked$} - navType$={navType$} customNavLink$={customNavLink$} /> ); - expect(component).toMatchSnapshot(); + expect(component.find('EuiHeader').exists()).toBeFalsy(); act(() => isVisible$.next(true)); component.update(); - expect(component).toMatchSnapshot(); + expect(component.find('EuiHeader').exists()).toBeTruthy(); + expect(component.find('nav[aria-label="Primary"]').exists()).toBeFalsy(); act(() => isLocked$.next(true)); component.update(); - expect(component).toMatchSnapshot(); - - act(() => navType$.next('legacy' as NavType)); - component.update(); + expect(component.find('nav[aria-label="Primary"]').exists()).toBeTruthy(); expect(component).toMatchSnapshot(); }); }); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index c0b3fc72930dc..7ec03ea4c6da6 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -23,8 +23,6 @@ import { EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiIcon, - EuiNavDrawer, - EuiShowFor, htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -43,14 +41,14 @@ import { import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { NavType, OnIsLockedUpdate } from './'; +import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; import { HeaderBadge } from './header_badge'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; import { HeaderHelpMenu } from './header_help_menu'; import { HeaderLogo } from './header_logo'; import { HeaderNavControls } from './header_nav_controls'; -import { NavDrawer } from './nav_drawer'; +import { HeaderActionMenu } from './header_action_menu'; export interface HeaderProps { kibanaVersion: string; @@ -68,27 +66,14 @@ export interface HeaderProps { helpExtension$: Observable; helpSupportUrl$: Observable; navControlsLeft$: Observable; + navControlsCenter$: Observable; navControlsRight$: Observable; basePath: HttpStart['basePath']; isLocked$: Observable; - navType$: Observable; loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; } -function renderMenuTrigger(toggleOpen: () => void) { - return ( - - - - ); -} - export function Header({ kibanaVersion, kibanaDocLink, @@ -98,125 +83,116 @@ export function Header({ homeHref, ...observables }: HeaderProps) { - const isVisible = useObservable(observables.isVisible$, true); - const navType = useObservable(observables.navType$, 'modern'); + const isVisible = useObservable(observables.isVisible$, false); const isLocked = useObservable(observables.isLocked$, false); - const [isOpen, setIsOpen] = useState(false); + const [isNavOpen, setIsNavOpen] = useState(false); if (!isVisible) { return ; } - const navDrawerRef = createRef(); const toggleCollapsibleNavRef = createRef(); const navId = htmlIdGenerator()(); - const className = classnames( - 'chrHeaderWrapper', // TODO #64541 - delete this - 'hide-for-sharing', - { - 'chrHeaderWrapper--navIsLocked': isLocked, - headerWrapper: navType === 'modern', - } - ); + const className = classnames('hide-for-sharing', 'headerGlobalNav'); return ( <>
- - - {navType === 'modern' ? ( +
+ , + ], + borders: 'none', + }, + { + ...(observables.navControlsCenter$ && { + items: [], + }), + borders: 'none', + }, + { + items: [ + , + , + ], + borders: 'none', + }, + ]} + /> + + + setIsOpen(!isOpen)} - aria-expanded={isOpen} - aria-pressed={isOpen} + onClick={() => setIsNavOpen(!isNavOpen)} + aria-expanded={isNavOpen} + aria-pressed={isNavOpen} aria-controls={navId} ref={toggleCollapsibleNavRef} > - ) : ( - // TODO #64541 - // Delete this block - - - {renderMenuTrigger(() => navDrawerRef.current?.toggleOpen())} - - - )} - - - + - - + + - + - + - - - - + + + + + + +
- -
-
- {navType === 'modern' ? ( - { - setIsOpen(false); - if (toggleCollapsibleNavRef.current) { - toggleCollapsibleNavRef.current.focus(); - } - }} - customNavLink$={observables.customNavLink$} - /> - ) : ( - // TODO #64541 - // Delete this block - - )} + { + setIsNavOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); + } + }} + customNavLink$={observables.customNavLink$} + />
); diff --git a/src/core/public/chrome/ui/header/header_action_menu.test.tsx b/src/core/public/chrome/ui/header/header_action_menu.test.tsx new file mode 100644 index 0000000000000..a124c8ab66969 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_action_menu.test.tsx @@ -0,0 +1,139 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; +import { HeaderActionMenu } from './header_action_menu'; +import { MountPoint, UnmountCallback } from '../../../types'; + +type MockedUnmount = jest.MockedFunction; + +describe('HeaderActionMenu', () => { + let component: ReactWrapper; + let menuMount$: BehaviorSubject; + let unmounts: Record; + + beforeEach(() => { + menuMount$ = new BehaviorSubject(undefined); + unmounts = {}; + }); + + const refresh = () => { + new Promise(async (resolve) => { + if (component) { + act(() => { + component.update(); + }); + } + setImmediate(() => resolve(component)); // flushes any pending promises + }); + }; + + const createMountPoint = (id: string, content: string = id): MountPoint => ( + root + ): MockedUnmount => { + const container = document.createElement('DIV'); + // eslint-disable-next-line no-unsanitized/property + container.innerHTML = content; + root.appendChild(container); + const unmount = jest.fn(() => container.remove()); + unmounts[id] = unmount; + return unmount; + }; + + it('mounts the current value of the provided observable', async () => { + component = mount(); + + act(() => { + menuMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + }); + + it('clears the content of the component when emitting undefined', async () => { + component = mount(); + + act(() => { + menuMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + + act(() => { + menuMount$.next(undefined); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
"` + ); + }); + + it('updates the dom when a new mount point is emitted', async () => { + component = mount(); + + act(() => { + menuMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + + act(() => { + menuMount$.next(createMountPoint('BAR')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
BAR
"` + ); + }); + + it('calls the previous mount point `unmount` when mounting a new mount point', async () => { + component = mount(); + + act(() => { + menuMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(Object.keys(unmounts)).toEqual(['FOO']); + expect(unmounts.FOO).not.toHaveBeenCalled(); + + act(() => { + menuMount$.next(createMountPoint('BAR')); + }); + await refresh(); + + expect(Object.keys(unmounts)).toEqual(['FOO', 'BAR']); + expect(unmounts.FOO).toHaveBeenCalledTimes(1); + expect(unmounts.BAR).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/chrome/ui/header/header_action_menu.tsx b/src/core/public/chrome/ui/header/header_action_menu.tsx new file mode 100644 index 0000000000000..3a7a09608ba66 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_action_menu.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { FC, useRef, useLayoutEffect, useState } from 'react'; +import { Observable } from 'rxjs'; +import { MountPoint, UnmountCallback } from '../../../types'; + +interface HeaderActionMenuProps { + actionMenu$: Observable; +} + +export const HeaderActionMenu: FC = ({ actionMenu$ }) => { + // useObservable relies on useState under the hood. The signature is type SetStateAction = S | ((prevState: S) => S); + // As we got a Observable here, React's setState setter assume he's getting a `(prevState: S) => S` signature, + // therefore executing the mount method, causing everything to crash. + // piping the observable before calling `useObservable` causes the effect to always having a new reference, as + // the piped observable is a new instance on every render, causing infinite loops. + // this is why we use `useLayoutEffect` manually here. + const [mounter, setMounter] = useState<{ mount: MountPoint | undefined }>({ mount: undefined }); + useLayoutEffect(() => { + const s = actionMenu$.subscribe((value) => { + setMounter({ mount: value }); + }); + return () => s.unsubscribe(); + }, [actionMenu$]); + + const elementRef = useRef(null); + const unmountRef = useRef(null); + + useLayoutEffect(() => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + + if (mounter.mount && elementRef.current) { + try { + unmountRef.current = mounter.mount(elementRef.current); + } catch (e) { + // TODO: use client-side logger when feature is implemented + // eslint-disable-next-line no-console + console.error(e); + } + } + }, [mounter]); + + return
; +}; diff --git a/src/core/public/chrome/ui/header/header_logo.tsx b/src/core/public/chrome/ui/header/header_logo.tsx index 9bec946b6b76e..dee93ecb1a804 100644 --- a/src/core/public/chrome/ui/header/header_logo.tsx +++ b/src/core/public/chrome/ui/header/header_logo.tsx @@ -105,6 +105,8 @@ export function HeaderLogo({ href, navigateToApp, ...observables }: Props) { aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel', { defaultMessage: 'Go to home page', })} - /> + > + Elastic + ); } diff --git a/src/core/public/chrome/ui/header/header_nav_controls.tsx b/src/core/public/chrome/ui/header/header_nav_controls.tsx index 0941f7b27b662..8d9d8097fd8e3 100644 --- a/src/core/public/chrome/ui/header/header_nav_controls.tsx +++ b/src/core/public/chrome/ui/header/header_nav_controls.tsx @@ -26,7 +26,7 @@ import { HeaderExtension } from './header_extension'; interface Props { navControls$: Observable; - side: 'left' | 'right'; + side?: 'left' | 'right'; } export function HeaderNavControls({ navControls$, side }: Props) { @@ -41,7 +41,10 @@ export function HeaderNavControls({ navControls$, side }: Props) { return ( <> {navControls.map((navControl: ChromeNavControl, index: number) => ( - + ))} diff --git a/src/core/public/chrome/ui/header/nav_drawer.tsx b/src/core/public/chrome/ui/header/nav_drawer.tsx deleted file mode 100644 index fc080fbafc303..0000000000000 --- a/src/core/public/chrome/ui/header/nav_drawer.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { EuiHorizontalRule, EuiNavDrawer, EuiNavDrawerGroup } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { useObservable } from 'react-use'; -import { Observable } from 'rxjs'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; -import { InternalApplicationStart } from '../../../application/types'; -import { HttpStart } from '../../../http'; -import { OnIsLockedUpdate } from './'; -import { createEuiListItem, createRecentNavLink } from './nav_link'; -import { RecentLinks } from './recent_links'; - -export interface Props { - appId$: InternalApplicationStart['currentAppId$']; - basePath: HttpStart['basePath']; - isLocked?: boolean; - navLinks$: Observable; - recentlyAccessed$: Observable; - navigateToApp: CoreStart['application']['navigateToApp']; - onIsLockedUpdate?: OnIsLockedUpdate; -} - -function NavDrawerRenderer( - { isLocked, onIsLockedUpdate, basePath, navigateToApp, ...observables }: Props, - ref: React.Ref -) { - const appId = useObservable(observables.appId$, ''); - const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); - const recentNavLinks = useObservable(observables.recentlyAccessed$, []).map((link) => - createRecentNavLink(link, navLinks, basePath) - ); - - return ( - - {RecentLinks({ recentNavLinks })} - - - createEuiListItem({ - link, - appId, - basePath, - navigateToApp, - dataTestSubj: 'navDrawerAppsMenuLink', - }) - )} - aria-label={i18n.translate('core.ui.primaryNavList.screenReaderLabel', { - defaultMessage: 'Primary navigation links', - })} - /> - - ); -} - -export const NavDrawer = React.forwardRef(NavDrawerRenderer); diff --git a/src/core/public/index.scss b/src/core/public/index.scss index c2ad2841d5a77..6ba9254e5d381 100644 --- a/src/core/public/index.scss +++ b/src/core/public/index.scss @@ -1,6 +1,6 @@ +@import './variables'; @import './core'; @import './chrome/index'; @import './overlays/index'; @import './rendering/index'; @import './styles/index'; - diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d90b8f780b674..a9bea7bcfdef1 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -272,10 +272,13 @@ export interface ChromeNavControl { // @public export interface ChromeNavControls { + // @internal (undocumented) + getCenter$(): Observable; // @internal (undocumented) getLeft$(): Observable; // @internal (undocumented) getRight$(): Observable; + registerCenter(navControl: ChromeNavControl): void; registerLeft(navControl: ChromeNavControl): void; registerRight(navControl: ChromeNavControl): void; } @@ -345,7 +348,6 @@ export interface ChromeStart { getHelpExtension$(): Observable; getIsNavDrawerLocked$(): Observable; getIsVisible$(): Observable; - getNavType$(): Observable; navControls: ChromeNavControls; navLinks: ChromeNavLinks; recentlyAccessed: ChromeRecentlyAccessed; diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index 211e9c03beea5..b806ac270331d 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,5 +1,4 @@ -@import '@elastic/eui/src/global_styling/variables/header'; -@import '@elastic/eui/src/components/nav_drawer/variables'; +@include euiHeaderAffordForFixed($kbnHeaderOffset); /** * stretch the root element of the Kibana application to set the base-size that @@ -12,74 +11,11 @@ min-height: 100%; } -// TODO #64541 -// Delete this block -.chrHeaderWrapper:not(.headerWrapper) ~ .app-wrapper { +.app-wrapper { display: flex; flex-flow: column nowrap; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - z-index: 5; margin: 0 auto; - - &:not(.hidden-chrome) { - top: $euiHeaderChildSize; - left: $euiHeaderChildSize; - - // HOTFIX: Temporary fix for flyouts not inside portals - // SASSTODO: Find an actual solution - .euiFlyout { - top: $euiHeaderChildSize; - height: calc(100% - #{$euiHeaderChildSize}); - } - - @include euiBreakpoint('xs', 's') { - left: 0; - } - } - - /** - * 1. Dirty, but we need to override the .kbnGlobalNav-isOpen state - * when we're looking at the log-in screen. - */ - &.hidden-chrome { - left: 0 !important; /* 1 */ - } - - .navbar-right { - margin-right: 0; - } -} - -// TODO #64541 -// Delete this block -@include euiBreakpoint('xl') { - .chrHeaderWrapper--navIsLocked:not(.headerWrapper) { - ~ .app-wrapper:not(.hidden-chrome) { - // Shrink the content from the left so it's no longer overlapped by the nav drawer (ALWAYS) - left: $euiNavDrawerWidthExpanded !important; // sass-lint:disable-line no-important - transition: left $euiAnimSpeedFast $euiAnimSlightResistance; - } - } -} - -// TODO #64541 -// Remove .headerWrapper and header conditionals -.headerWrapper ~ .app-wrapper, -:not(header) ~ .app-wrapper { - display: flex; - flex-flow: column nowrap; - margin: 0 auto; - min-height: calc(100vh - #{$euiHeaderHeightCompensation}); - - @include internetExplorerOnly { - // IE specific bug with min-height in flex container, described in the next link - // https://github.com/philipwalton/flexbugs#3-min-height-on-a-flex-container-wont-apply-to-its-flex-items - height: calc(100vh - #{$euiHeaderHeightCompensation}); - } + min-height: calc(100vh - #{$kbnHeaderOffset}); &.hidden-chrome { min-height: 100vh; diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index 427c6b7735435..bfb07c1b51427 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -7,17 +7,6 @@ // Application Layout -// chrome-context -// TODO #64541 -// Delete this block -.chrHeaderWrapper:not(.headerWrapper) .content { - display: flex; - flex-flow: row nowrap; - width: 100%; - height: 100%; - overflow: hidden; -} - .application, .app-container { > * { diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index bbc3f3632bf64..8c9e3847844d9 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -121,7 +121,7 @@ export class AdvancedSettingsComponent extends Component< setTimeout(() => { const id = hash.replace('#', ''); const element = document.getElementById(id); - const globalNavOffset = document.getElementById('headerGlobalNav')?.offsetHeight || 0; + const globalNavOffset = document.getElementById('globalHeaderBars')?.offsetHeight || 0; if (element) { element.scrollIntoView(); diff --git a/src/plugins/advanced_settings/public/management_app/components/_index.scss b/src/plugins/advanced_settings/public/management_app/components/_index.scss deleted file mode 100644 index d2d2e38947f76..0000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './form/index'; diff --git a/src/plugins/advanced_settings/public/management_app/components/form/_form.scss b/src/plugins/advanced_settings/public/management_app/components/form/_form.scss deleted file mode 100644 index 8d768d200fdd2..0000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/form/_form.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import '@elastic/eui/src/global_styling/variables/header'; -@import '@elastic/eui/src/components/nav_drawer/variables'; - -// TODO #64541 -// Delete this whole file -.mgtAdvancedSettingsForm__bottomBar { - margin-left: $euiNavDrawerWidthCollapsed; - z-index: 9; // Puts it inuder the nav drawer when expanded - &--pushForNav { - margin-left: $euiNavDrawerWidthExpanded; - } - @include euiBreakpoint('xs', 's') { - margin-left: 0; - } -} diff --git a/src/plugins/advanced_settings/public/management_app/components/form/_index.scss b/src/plugins/advanced_settings/public/management_app/components/form/_index.scss deleted file mode 100644 index 2ef4ef1d20ce9..0000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/form/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './form'; diff --git a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx index 5533f684870d9..0378d816fd2c3 100644 --- a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx @@ -18,7 +18,6 @@ */ import React, { PureComponent, Fragment } from 'react'; -import classNames from 'classnames'; import { EuiFlexGroup, @@ -45,7 +44,6 @@ import { Field, getEditableValue } from '../field'; import { FieldSetting, SettingsChanges, FieldState } from '../../types'; type Category = string; -const NAV_IS_LOCKED_KEY = 'core.chrome.isLocked'; interface FormProps { settings: Record; @@ -326,23 +324,8 @@ export class Form extends PureComponent { renderBottomBar = () => { const areChangesInvalid = this.areChangesInvalid(); - - // TODO #64541 - // Delete these classes - let bottomBarClasses = ''; - const pageNav = this.props.settings.general.find( - (setting) => setting.name === 'pageNavigation' - ); - - if (pageNav?.value === 'legacy') { - bottomBarClasses = classNames('mgtAdvancedSettingsForm__bottomBar', { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'mgtAdvancedSettingsForm__bottomBar--pushForNav': - localStorage.getItem(NAV_IS_LOCKED_KEY) === 'true', - }); - } return ( - + ScopedHistory; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedObjects: SavedObjectsStart; restorePreviousUrl: () => void; } diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 92d6f2ed91dde..dd5eb1ee5ccaa 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -153,6 +153,7 @@ export class DashboardAppController { i18n: i18nStart, }, history, + setHeaderActionMenu, kbnUrlStateStorage, usageCollection, navigation, @@ -709,7 +710,13 @@ export class DashboardAppController { }; const dashboardNavBar = document.getElementById('dashboardChrome'); const updateNavBar = () => { - ReactDOM.render(, dashboardNavBar); + ReactDOM.render( + , + dashboardNavBar + ); }; const unmountNavBar = () => { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 49584f62215ea..5a45229a58a7d 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -310,7 +310,7 @@ export class DashboardPlugin id: DashboardConstants.DASHBOARDS_ID, title: 'Dashboard', order: 2500, - euiIconType: 'dashboardApp', + euiIconType: 'logoKibana', defaultPath: `#${DashboardConstants.LANDING_PAGE_PATH}`, updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.kibana, @@ -352,6 +352,7 @@ export class DashboardPlugin localStorage: new Storage(localStorage), usageCollection, scopedHistory: () => this.currentHistory!, + setHeaderActionMenu: params.setHeaderActionMenu, savedObjects, restorePreviousUrl, }; diff --git a/src/plugins/dev_tools/public/index.scss b/src/plugins/dev_tools/public/index.scss index c9d8dc7470656..4bec602ea42db 100644 --- a/src/plugins/dev_tools/public/index.scss +++ b/src/plugins/dev_tools/public/index.scss @@ -16,10 +16,6 @@ } } -.devApp { - height: 100%; -} - .devAppWrapper { display: flex; flex-direction: column; diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index fcc6a57361a94..8c4743c93fab3 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -60,7 +60,7 @@ export class DevToolsPlugin implements Plugin { defaultMessage: 'Dev Tools', }), updater$: this.appStateUpdater, - euiIconType: 'devToolsApp', + euiIconType: 'logoElastic', order: 9010, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { diff --git a/src/plugins/discover/public/application/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html index 94f13e1cd8132..e0e452aaa41c5 100644 --- a/src/plugins/discover/public/application/angular/discover.html +++ b/src/plugins/discover/public/application/angular/discover.html @@ -5,6 +5,7 @@

{{screenTitle}}

(uiActions = pluginUiActions); export const getUiActions = () => uiActions; +export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGetterSetter< + AppMountParameters['setHeaderActionMenu'] +>('headerActionMenuMounter'); + export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ setTrackedUrl: (url: string) => void; restorePreviousUrl: () => void; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index b6960c8a20abf..dd9b57b568e42 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -54,6 +54,7 @@ import { setUrlTracker, setAngularModule, setServices, + setHeaderActionMenuMounter, setUiActions, setScopedHistory, getScopedHistory, @@ -240,7 +241,7 @@ export class DiscoverPlugin title: 'Discover', updater$: this.appStateUpdater.asObservable(), order: 1000, - euiIconType: 'discoverApp', + euiIconType: 'logoKibana', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { @@ -251,6 +252,7 @@ export class DiscoverPlugin throw Error('Discover plugin method initializeInnerAngular is undefined'); } setScopedHistory(params.history); + setHeaderActionMenuMounter(params.setHeaderActionMenu); syncHistoryLocations(); appMounted(); const { @@ -264,6 +266,7 @@ export class DiscoverPlugin params.element.classList.add('dscAppWrapper'); const unmount = await renderApp(innerAngularName, params.element); return () => { + params.element.classList.remove('dscAppWrapper'); unmount(); appUnMounted(); }; diff --git a/src/plugins/kibana_legacy/public/angular/kbn_top_nav.js b/src/plugins/kibana_legacy/public/angular/kbn_top_nav.js index b3fbe8baadec3..c34e2487b32d4 100644 --- a/src/plugins/kibana_legacy/public/angular/kbn_top_nav.js +++ b/src/plugins/kibana_legacy/public/angular/kbn_top_nav.js @@ -74,6 +74,7 @@ export function createTopNavDirective() { export const createTopNavHelper = ({ TopNavMenu }) => (reactDirective) => { return reactDirective(TopNavMenu, [ ['config', { watchDepth: 'value' }], + ['setMenuMountPoint', { watchDepth: 'reference' }], ['disabledButtons', { watchDepth: 'reference' }], ['query', { watchDepth: 'reference' }], diff --git a/src/plugins/kibana_react/public/util/index.ts b/src/plugins/kibana_react/public/util/index.ts index a6f3f87535f46..030dd4aec0f12 100644 --- a/src/plugins/kibana_react/public/util/index.ts +++ b/src/plugins/kibana_react/public/util/index.ts @@ -19,3 +19,4 @@ export { toMountPoint } from './to_mount_point'; export { MountPointPortal } from './mount_point_portal'; +export { useIfMounted } from './utils'; diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.tsx index b762fba88791e..0249302763772 100644 --- a/src/plugins/kibana_react/public/util/mount_point_portal.tsx +++ b/src/plugins/kibana_react/public/util/mount_point_portal.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import React, { useRef, useEffect, useState, Component } from 'react'; import ReactDOM from 'react-dom'; import { MountPoint } from 'kibana/public'; +import { useIfMounted } from './utils'; interface MountPointPortalProps { setMountPoint: (mountPoint: MountPoint) => void; @@ -33,20 +34,30 @@ export const MountPointPortal: React.FC = ({ children, se // state used to force re-renders when the element changes const [shouldRender, setShouldRender] = useState(false); const el = useRef(); + const ifMounted = useIfMounted(); useEffect(() => { setMountPoint((element) => { - el.current = element; - setShouldRender(true); + ifMounted(() => { + el.current = element; + setShouldRender(true); + }); return () => { - setShouldRender(false); - el.current = undefined; + // the component can be unmounted from the dom before the portal target actually + // calls the `unmount` function. This is a no-op but show a scary warning in the console + // so we use a ifMounted effect to avoid it. + ifMounted(() => { + setShouldRender(false); + el.current = undefined; + }); }; }); return () => { - setShouldRender(false); - el.current = undefined; + ifMounted(() => { + setShouldRender(false); + el.current = undefined; + }); }; }, [setMountPoint]); diff --git a/src/plugins/kibana_react/public/util/utils.ts b/src/plugins/kibana_react/public/util/utils.ts new file mode 100644 index 0000000000000..23d4ac2ec4851 --- /dev/null +++ b/src/plugins/kibana_react/public/util/utils.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +export const useIfMounted = () => { + const isMounted = useRef(true); + useEffect( + () => () => { + isMounted.current = false; + }, + [] + ); + + const ifMounted = useCallback((func) => { + if (isMounted.current && func) { + func(); + } + }, []); + + return ifMounted; +}; diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 794bbc0d0613b..fafedf46c2bda 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -74,7 +74,7 @@ export class ManagementPlugin implements Plugin * > * { + // TEMP fix to adjust spacing between EuiHeaderList__list items + margin: 0 $euiSizeXS; } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index f21e5680e8f61..212bc19208ca8 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -164,7 +164,10 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); - expect(portalTarget.getElementsByTagName('BUTTON').length).toBe(menuItems.length); + + const buttons = portalTarget.querySelectorAll('button'); + expect(buttons.length).toBe(menuItems.length + 1); // should be n+1 buttons in mobile for popover button + expect(buttons[buttons.length - 1].getAttribute('aria-label')).toBe('Open navigation menu'); // last button should be mobile button }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index b284c60bac5de..a27addeb14393 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -18,7 +18,7 @@ */ import React, { ReactElement } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiHeaderLinks } from '@elastic/eui'; import classNames from 'classnames'; import { MountPoint } from '../../../../core/public'; @@ -81,31 +81,16 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { function renderItems(): ReactElement[] | null { if (!config || config.length === 0) return null; return config.map((menuItem: TopNavMenuData, i: number) => { - return ( - - - - ); + return ; }); } function renderMenu(className: string): ReactElement | null { if (!config || config.length === 0) return null; return ( - + {renderItems()} - + ); } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index b058ef0de448b..96a205b737273 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -19,9 +19,7 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; - -import { EuiButton } from '@elastic/eui'; +import { EuiToolTip, EuiButton, EuiHeaderLink } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -50,13 +48,13 @@ export function TopNavMenuItem(props: TopNavMenuData) { }; const btn = props.emphasize ? ( - + {upperFirst(props.label || props.id!)} ) : ( - + {upperFirst(props.label || props.id!)} - + ); const tooltip = getTooltip(); diff --git a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx index 888b807b5296f..628cfde18b0d5 100644 --- a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx +++ b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx @@ -68,7 +68,7 @@ export const NewsfeedNavButton = ({ apiFetchResult }: Props) => { aria-label="Newsfeed menu" onClick={showFlyout} > - + {showBadge ? ( ▪ diff --git a/src/plugins/timelion/public/_app.scss b/src/plugins/timelion/public/_app.scss index 3142e1d23cf10..8b9078caba5a8 100644 --- a/src/plugins/timelion/public/_app.scss +++ b/src/plugins/timelion/public/_app.scss @@ -1,8 +1,5 @@ -@import '@elastic/eui/src/global_styling/variables/header'; - .timApp { position: relative; - min-height: calc(100vh - #{$euiHeaderChildSize}); background: $euiColorEmptyShade; [ng-click] { diff --git a/src/plugins/timelion/public/plugin.ts b/src/plugins/timelion/public/plugin.ts index a92ced20cb6d1..24d1b9eb3fb65 100644 --- a/src/plugins/timelion/public/plugin.ts +++ b/src/plugins/timelion/public/plugin.ts @@ -91,7 +91,7 @@ export class TimelionPlugin implements Plugin { title: 'Timelion', order: 8000, defaultPath: '#/', - euiIconType: 'timelionApp', + euiIconType: 'logoKibana', category: DEFAULT_APP_CATEGORIES.kibana, updater$: this.appStateUpdater.asObservable(), mount: async (params: AppMountParameters) => { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index 130561b6245ae..dfd3c09f51ed5 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -61,6 +61,7 @@ const TopNav = ({ }: VisualizeTopNavProps) => { const { services } = useKibana(); const { TopNavMenu } = services.navigation.ui; + const { setHeaderActionMenu } = services; const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); const openInspector = useCallback(() => { @@ -151,6 +152,7 @@ const TopNav = ({ void; scopedHistory: ScopedHistory; dashboard: DashboardStart; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 95d5343d5d695..86159a13379a1 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -140,7 +140,7 @@ export class VisualizePlugin id: 'visualize', title: 'Visualize', order: 8000, - euiIconType: 'visualizeApp', + euiIconType: 'logoKibana', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.kibana, updater$: this.appStateUpdater.asObservable(), @@ -196,12 +196,14 @@ export class VisualizePlugin scopedHistory: params.history, restorePreviousUrl, dashboard: pluginsStart.dashboard, + setHeaderActionMenu: params.setHeaderActionMenu, }; params.element.classList.add('visAppWrapper'); const { renderApp } = await import('./application'); const unmount = renderApp(params, services); return () => { + params.element.classList.remove('visAppWrapper'); unlistenParentHistory(); unmount(); appUnMounted(); diff --git a/test/accessibility/apps/management.ts b/test/accessibility/apps/management.ts index b37500f5b15dd..08177f54ee881 100644 --- a/test/accessibility/apps/management.ts +++ b/test/accessibility/apps/management.ts @@ -64,8 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - // Will be enabling this and field formatters after this issue is addressed: https://github.com/elastic/kibana/issues/60030 - it.skip('Edit field type', async () => { + it('Edit field type', async () => { await PageObjects.settings.clickEditFieldFormat(); await a11y.testAppSnapshot(); }); diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 8a726cee444c1..237dc8946ae0e 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -39,7 +39,7 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo const find = getService('find'); const browser = getService('browser'); const testSubjects = getService('testSubjects'); - const { header, common } = getPageObjects(['header', 'common']); + const { header } = getPageObjects(['header']); const kibanaServer = getService('kibanaServer'); class TimePicker { @@ -127,7 +127,7 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo await testSubjects.click('superDatePickerAbsoluteTab'); await testSubjects.click('superDatePickerAbsoluteDateInput'); await this.inputValue('superDatePickerAbsoluteDateInput', toTime); - await common.sleep(500); + await browser.pressKeys(browser.keys.ESCAPE); // close popover because sometimes browser can't find start input // set from time await testSubjects.click('superDatePickerstartDatePopoverButton'); diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index 3f9775d7a75f3..aebdc734cfb39 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -33,7 +33,7 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider */ class ListingTable { private async getSearchFilter() { - const searchFilter = await find.allByCssSelector('.euiFieldSearch'); + const searchFilter = await find.allByCssSelector('main .euiFieldSearch'); return searchFilter[0]; } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7d21f958bb80b..bdd0fbea35fa8 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -2,7 +2,10 @@ "prefix": "xpack", "paths": { "xpack.actions": "plugins/actions", - "xpack.uiActionsEnhanced": ["plugins/ui_actions_enhanced", "examples/ui_actions_enhanced_examples"], + "xpack.uiActionsEnhanced": [ + "plugins/ui_actions_enhanced", + "examples/ui_actions_enhanced_examples" + ], "xpack.alerts": "plugins/alerts", "xpack.eventLog": "plugins/event_log", "xpack.alertingBuiltins": "plugins/alerting_builtins", @@ -21,6 +24,7 @@ "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], + "xpack.globalSearchBar": ["plugins/global_search_bar"], "xpack.graph": ["plugins/graph"], "xpack.grokDebugger": "plugins/grokdebugger", "xpack.idxMgmt": "plugins/index_management", diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 33e6a4b50a742..51ac6673251fb 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -105,7 +105,7 @@ export class ApmPlugin implements Plugin { id: 'apm', title: 'APM', order: 8300, - euiIconType: 'apmApp', + euiIconType: 'logoObservability', appRoute: '/app/apm', icon: 'plugins/apm/public/icon.svg', category: DEFAULT_APP_CATEGORIES.observability, @@ -125,6 +125,7 @@ export class ApmPlugin implements Plugin { id: 'csm', title: 'Client Side Monitoring', order: 8500, + euiIconType: 'logoObservability', category: DEFAULT_APP_CATEGORIES.observability, async mount(params: AppMountParameters) { diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot index 87205b363e697..14791cd3d8b25 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot @@ -355,181 +355,3 @@ exports[`Storyshots components/Assets/Asset marker 1`] = `
`; - -exports[`Storyshots components/Assets/Asset redux 1`] = ` -
-
-
-
-
- Asset thumbnail -
-
-
-
-

- - airplane - -
- - - ( - 1 - kb) - - -

-
-
-
-
- - - -
-
- -
- -
-
-
-
- -
- -
-
-
-
- - - -
-
-
-
-
-`; diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index 7110a22408fe2..edd681a2c33e8 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -1,5 +1,5 @@ body.canvas-isFullscreen { // sass-lint:disable-line no-qualifying-elements - // following two rules are for overriding the header bar padding + // following two rules are for overriding the header bar padding &.euiBody--headerIsFixed { padding-top: 0; } @@ -13,28 +13,17 @@ body.canvas-isFullscreen { // sass-lint:disable-line no-qualifying-elements padding-left: 0 !important; // sass-lint:disable-line no-important } - // hide global loading indicator .kbnLoadingIndicator { display: none; } - // remove space for global nav elements - // TODO #64541 - // Can delete this block - .chrHeaderWrapper ~ .app-wrapper { - // Override locked nav at all breakpoints - left: 0 !important; // sass-lint:disable-line no-important - top: 0; - } - // set the background color .canvasLayout { background: $euiColorInk; } // hide all the interface parts - .chrHeaderWrapper, // K7 global top nav .canvasLayout__stageHeader, .canvasLayout__sidebar, .canvasLayout__footer, diff --git a/x-pack/plugins/canvas/public/lib/fullscreen.js b/x-pack/plugins/canvas/public/lib/fullscreen.js index bb9990d4f5457..6ad52d591c9c4 100644 --- a/x-pack/plugins/canvas/public/lib/fullscreen.js +++ b/x-pack/plugins/canvas/public/lib/fullscreen.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { platformService } from '../services'; + export const fullscreenClass = 'canvas-isFullscreen'; export function setFullscreen(fullscreen, doc = document) { @@ -13,8 +15,10 @@ export function setFullscreen(fullscreen, doc = document) { const isFullscreen = bodyClassList.contains(fullscreenClass); if (enabled && !isFullscreen) { + platformService.getService().setFullscreen(false); bodyClassList.add(fullscreenClass); } else if (!enabled && isFullscreen) { bodyClassList.remove(fullscreenClass); + platformService.getService().setFullscreen(true); } } diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index b02fb9db28612..fbca1e51bd5c6 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -88,7 +88,7 @@ export class CanvasPlugin category: DEFAULT_APP_CATEGORIES.kibana, id: 'canvas', title: 'Canvas', - euiIconType: 'canvasApp', + euiIconType: 'logoKibana', order: 3000, updater$: this.appUpdater, mount: async (params: AppMountParameters) => { diff --git a/x-pack/plugins/canvas/public/services/platform.ts b/x-pack/plugins/canvas/public/services/platform.ts index 92c378e9aa597..a27c57f32cf9f 100644 --- a/x-pack/plugins/canvas/public/services/platform.ts +++ b/x-pack/plugins/canvas/public/services/platform.ts @@ -10,6 +10,7 @@ import { IUiSettingsClient, ChromeBreadcrumb, IBasePath, + ChromeStart, } from '../../../../../src/core/public'; import { CanvasServiceFactory } from '.'; @@ -22,6 +23,7 @@ export interface PlatformService { getUISetting: (key: string, defaultValue?: any) => any; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; setRecentlyAccessed: (link: string, label: string, id: string) => void; + setFullscreen: ChromeStart['setIsVisible']; // TODO: these should go away. We want thin accessors, not entire objects. // Entire objects are hard to mock, and hide our dependency on the external service. @@ -45,6 +47,7 @@ export const platformServiceFactory: CanvasServiceFactory = ( getUISetting: coreStart.uiSettings.get.bind(coreStart.uiSettings), setBreadcrumbs: coreStart.chrome.setBreadcrumbs, setRecentlyAccessed: coreStart.chrome.recentlyAccessed.add, + setFullscreen: coreStart.chrome.setIsVisible, // TODO: these should go away. We want thin accessors, not entire objects. // Entire objects are hard to mock, and hide our dependency on the external service. diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts index 9ada579573502..bef3b2609537c 100644 --- a/x-pack/plugins/canvas/public/services/stubs/platform.ts +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -20,4 +20,5 @@ export const platformService: PlatformService = { getSavedObjects: noop, getSavedObjectsClient: noop, getUISettings: noop, + setFullscreen: noop, }; diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index c6ca0d532ce07..d6a51e8b482d0 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -29,6 +29,7 @@ export const ENTERPRISE_SEARCH_PLUGIN = { }), ], URL: '/app/enterprise_search/overview', + LOGO: 'logoEnterpriseSearch', }; export const APP_SEARCH_PLUGIN = { diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index b735db7c49520..63f334811ce31 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -5,26 +5,25 @@ */ import { - Plugin, - PluginInitializerContext, + AppMountParameters, CoreSetup, CoreStart, - AppMountParameters, HttpSetup, + Plugin, + PluginInitializerContext, } from 'src/core/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { LicensingPluginSetup } from '../../licensing/public'; - -import { IInitialAppData } from '../common/types'; import { - ENTERPRISE_SEARCH_PLUGIN, APP_SEARCH_PLUGIN, + ENTERPRISE_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, } from '../common/constants'; +import { IInitialAppData } from '../common/types'; import { ExternalUrl, IExternalUrl } from './applications/shared/enterprise_search_url'; export interface ClientConfigType { @@ -73,6 +72,7 @@ export class EnterpriseSearchPlugin implements Plugin { core.application.register({ id: APP_SEARCH_PLUGIN.ID, title: APP_SEARCH_PLUGIN.NAME, + euiIconType: ENTERPRISE_SEARCH_PLUGIN.LOGO, appRoute: APP_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { @@ -92,6 +92,7 @@ export class EnterpriseSearchPlugin implements Plugin { core.application.register({ id: WORKPLACE_SEARCH_PLUGIN.ID, title: WORKPLACE_SEARCH_PLUGIN.NAME, + euiIconType: ENTERPRISE_SEARCH_PLUGIN.LOGO, appRoute: WORKPLACE_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { diff --git a/x-pack/plugins/global_search_bar/README.md b/x-pack/plugins/global_search_bar/README.md new file mode 100644 index 0000000000000..e16aac39e3f4e --- /dev/null +++ b/x-pack/plugins/global_search_bar/README.md @@ -0,0 +1,3 @@ +# Kibana GlobalSearchBar plugin + +The GlobalSearchBar plugin provides a search interface for navigating Kibana. (It is the UI to the GlobalSearch plugin.) diff --git a/x-pack/plugins/global_search_bar/kibana.json b/x-pack/plugins/global_search_bar/kibana.json new file mode 100644 index 0000000000000..2d4ffa34d346f --- /dev/null +++ b/x-pack/plugins/global_search_bar/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "globalSearchBar", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["globalSearch"], + "optionalPlugins": [], + "configPath": ["xpack", "global_search_bar"] +} diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap new file mode 100644 index 0000000000000..f7e4bfd1c961c --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar correctly filters and sorts results 1`] = ` +Array [ + Object { + "append": undefined, + "className": "euiSelectableTemplateSitewide__listItem", + "key": "Canvas", + "label": "Canvas", + "prepend": undefined, + "title": "Canvasundefinedundefined", + "url": "/app/test/Canvas", + }, + Object { + "append": undefined, + "className": "euiSelectableTemplateSitewide__listItem", + "key": "Discover", + "label": "Discover", + "prepend": undefined, + "title": "Discoverundefinedundefined", + "url": "/app/test/Discover", + }, + Object { + "append": undefined, + "className": "euiSelectableTemplateSitewide__listItem", + "key": "Graph", + "label": "Graph", + "prepend": undefined, + "title": "Graphundefinedundefined", + "url": "/app/test/Graph", + }, +] +`; + +exports[`SearchBar correctly filters and sorts results 2`] = ` +Array [ + Object { + "append": undefined, + "className": "euiSelectableTemplateSitewide__listItem", + "key": "Discover", + "label": "Discover", + "prepend": undefined, + "title": "Discoverundefinedundefined", + "url": "/app/test/Discover", + }, + Object { + "append": undefined, + "className": "euiSelectableTemplateSitewide__listItem", + "key": "My Dashboard", + "label": "My Dashboard", + "meta": Array [ + Object { + "text": "Test", + }, + ], + "prepend": undefined, + "title": "My Dashboard • Test", + "url": "/app/test/My Dashboard", + }, +] +`; + +exports[`SearchBar supports keyboard shortcuts 1`] = ` + +`; diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx new file mode 100644 index 0000000000000..0d1e8725b4911 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { wait } from '@testing-library/react'; +import { of } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { + GlobalSearchBatchedResults, + GlobalSearchPluginStart, + GlobalSearchResult, +} from '../../../global_search/public'; +import { globalSearchPluginMock } from '../../../global_search/public/mocks'; +import { SearchBar } from '../components/search_bar'; + +type Result = { id: string; type: string } | string; + +const createResult = (result: Result): GlobalSearchResult => { + const id = typeof result === 'string' ? result : result.id; + const type = typeof result === 'string' ? 'application' : result.type; + + return { + id, + type, + title: id, + url: `/app/test/${id}`, + score: 42, + }; +}; + +const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({ + results: results.map(createResult), +}); + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); + +const getSelectableProps: any = (component: any) => component.find('EuiSelectable').props(); +const getSearchProps: any = (component: any) => component.find('EuiFieldSearch').props(); + +describe('SearchBar', () => { + let searchService: GlobalSearchPluginStart; + let findSpy: jest.SpyInstance; + + beforeEach(() => { + searchService = globalSearchPluginMock.createStartContract(); + findSpy = jest.spyOn(searchService, 'find'); + jest.useFakeTimers(); + }); + + it('correctly filters and sorts results', async () => { + const navigate = jest.fn(); + findSpy + .mockReturnValueOnce( + of( + createBatch('Discover', 'Canvas'), + createBatch({ id: 'Visualize', type: 'test' }, 'Graph') + ) + ) + .mockReturnValueOnce(of(createBatch('Discover', { id: 'My Dashboard', type: 'test' }))); + + const component = mountWithIntl( + + ); + + expect(findSpy).toHaveBeenCalledTimes(0); + component.find('input[data-test-subj="header-search"]').simulate('focus'); + jest.runAllTimers(); + component.update(); + expect(findSpy).toHaveBeenCalledTimes(1); + expect(findSpy).toHaveBeenCalledWith('', {}); + expect(getSelectableProps(component).options).toMatchSnapshot(); + await wait(() => getSearchProps(component).onSearch('d')); + jest.runAllTimers(); + component.update(); + expect(getSelectableProps(component).options).toMatchSnapshot(); + expect(findSpy).toHaveBeenCalledTimes(2); + expect(findSpy).toHaveBeenCalledWith('d', {}); + }); + + it('supports keyboard shortcuts', () => { + mountWithIntl(); + + const searchEvent = new KeyboardEvent('keydown', { + key: '/', + ctrlKey: true, + metaKey: true, + } as any); + window.dispatchEvent(searchEvent); + + expect(document.activeElement).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx new file mode 100644 index 0000000000000..25ca1b07321c7 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiSelectableTemplateSitewide, + EuiSelectableTemplateSitewideOption, + EuiText, + EuiSelectableMessage, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ApplicationStart } from 'kibana/public'; +import React, { useCallback, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import useEvent from 'react-use/lib/useEvent'; +import useMountedState from 'react-use/lib/useMountedState'; +import { GlobalSearchPluginStart, GlobalSearchResult } from '../../../global_search/public'; + +interface Props { + globalSearch: GlobalSearchPluginStart['find']; + navigateToUrl: ApplicationStart['navigateToUrl']; +} + +const clearField = (field: HTMLInputElement) => { + const nativeInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + const nativeInputValueSetter = nativeInputValue ? nativeInputValue.set : undefined; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(field, ''); + } + + field.dispatchEvent(new Event('change')); +}; + +const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); +const blurEvent = new FocusEvent('blur'); + +export function SearchBar({ globalSearch, navigateToUrl }: Props) { + const isMounted = useMountedState(); + const [searchValue, setSearchValue] = useState(''); + const [searchRef, setSearchRef] = useState(null); + const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); + const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + + const setOptions = useCallback( + (_options: GlobalSearchResult[]) => { + if (!isMounted()) return; + + _setOptions([ + ..._options.map((option) => ({ + key: option.id, + label: option.title, + url: option.url, + ...(option.icon && { icon: { type: option.icon } }), + ...(option.type && + option.type !== 'application' && { meta: [{ text: cleanMeta(option.type) }] }), + })), + ]); + }, + [isMounted, _setOptions] + ); + + useDebounce( + () => { + let arr: GlobalSearchResult[] = []; + globalSearch(searchValue, {}).subscribe({ + next: ({ results }) => { + if (searchValue.length > 0) { + arr = [...results, ...arr].sort((a, b) => { + if (a.score < b.score) return 1; + if (a.score > b.score) return -1; + return 0; + }); + setOptions(arr); + return; + } + + // if searchbar is empty, filter to only applications and sort alphabetically + results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); + + arr = [...results, ...arr].sort((a, b) => { + const titleA = a.title.toUpperCase(); // ignore upper and lowercase + const titleB = b.title.toUpperCase(); // ignore upper and lowercase + if (titleA < titleB) return -1; + if (titleA > titleB) return 1; + return 0; + }); + + setOptions(arr); + }, + error: () => { + // TODO #74430 - add telemetry to see if errors are happening + // Not doing anything on error right now because it'll either just show the previous + // results or empty results which is basically what we want anyways + }, + complete: () => {}, + }); + }, + 250, + [searchValue] + ); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === '/' && (isMac ? event.metaKey : event.ctrlKey)) { + if (searchRef) { + event.preventDefault(); + searchRef.focus(); + } + } + }; + + const onChange = (selected: EuiSelectableTemplateSitewideOption[]) => { + // @ts-ignore - ts error is "union type is too complex to express" + const { url } = selected.find(({ checked }) => checked === 'on'); + + navigateToUrl(url); + (document.activeElement as HTMLElement).blur(); + if (searchRef) { + clearField(searchRef); + searchRef.dispatchEvent(blurEvent); + } + }; + + useEvent('keydown', onKeyDown); + + return ( + +

+ +

+

+ +

+ + } + popoverFooter={ + + + + + + ), + commandDescription: ( + + + {isMac ? ( + + ) : ( + + )} + + + ), + }} + /> + Shortcut, + how: ( + + {isMac ? 'Command + /' : 'Control + /'} + + ), + }} + /> + + + } + /> + ); +} diff --git a/x-pack/plugins/global_search_bar/public/index.ts b/x-pack/plugins/global_search_bar/public/index.ts new file mode 100644 index 0000000000000..9e11c43ce872c --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { GlobalSearchBarPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, {}, {}> = () => new GlobalSearchBarPlugin(); diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx new file mode 100644 index 0000000000000..0c8cc2e64726a --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, Plugin } from 'src/core/public'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import ReactDOM from 'react-dom'; +import { ApplicationStart } from 'kibana/public'; +import { SearchBar } from '../public/components/search_bar'; +import { GlobalSearchPluginStart } from '../../global_search/public'; + +export interface GlobalSearchBarPluginStartDeps { + globalSearch: GlobalSearchPluginStart; +} + +export class GlobalSearchBarPlugin implements Plugin<{}, {}> { + public async setup() { + return {}; + } + + public start(core: CoreStart, { globalSearch }: GlobalSearchBarPluginStartDeps) { + core.chrome.navControls.registerCenter({ + order: 1000, + mount: (target) => this.mount(target, globalSearch, core.application.navigateToUrl), + }); + return {}; + } + + private mount( + targetDomElement: HTMLElement, + globalSearch: GlobalSearchPluginStart, + navigateToUrl: ApplicationStart['navigateToUrl'] + ) { + ReactDOM.render( + + + , + targetDomElement + ); + + return () => ReactDOM.unmountComponentAtNode(targetDomElement); + } +} diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 50385008d7b2b..10bbb2e8ec6c7 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -1,6 +1,6 @@
- +
diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index fd2b96e0570f6..183f8bddead11 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -53,6 +53,7 @@ export function initGraphApp(angularModule, deps) { graphSavePolicy, overlays, savedObjects, + setHeaderActionMenu, } = deps; const app = angularModule; @@ -465,6 +466,7 @@ export function initGraphApp(angularModule, deps) { }; // ===== Menubar configuration ========= + $scope.setHeaderActionMenu = setHeaderActionMenu; $scope.topNavMenu = []; $scope.topNavMenu.push({ key: 'new', diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index a9ba464016157..90e87ff4ec85e 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -27,6 +27,7 @@ import { SavedObjectsClientContract, ToastsStart, OverlayStart, + AppMountParameters, } from 'kibana/public'; // @ts-ignore import { initGraphApp } from './app'; @@ -73,6 +74,7 @@ export interface GraphDependencies { overlays: OverlayStart; savedObjects: SavedObjectsStart; kibanaLegacy: KibanaLegacyStart; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: GraphDependencies) => { diff --git a/x-pack/plugins/graph/public/components/_app.scss b/x-pack/plugins/graph/public/components/_app.scss index f6984f5369a30..b9b23e596a05e 100644 --- a/x-pack/plugins/graph/public/components/_app.scss +++ b/x-pack/plugins/graph/public/components/_app.scss @@ -1,3 +1,3 @@ .gphGraph__bar { - margin: 0px $euiSizeS $euiSizeS $euiSizeS; + margin: $euiSizeS; } diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index e452b74ab35e8..6cf9d8be07bc9 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -74,7 +74,7 @@ export class GraphPlugin title: 'Graph', order: 6000, appRoute: '/app/graph', - euiIconType: 'graphApp', + euiIconType: 'logoKibana', category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 8a1264d254e40..66715b3fee28b 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -52,7 +52,7 @@ export class Plugin implements InfraClientPluginClass { title: i18n.translate('xpack.infra.logs.pluginTitle', { defaultMessage: 'Logs', }), - euiIconType: 'logsApp', + euiIconType: 'logoObservability', order: 8100, appRoute: '/app/logs', category: DEFAULT_APP_CATEGORIES.observability, @@ -70,7 +70,7 @@ export class Plugin implements InfraClientPluginClass { title: i18n.translate('xpack.infra.metrics.pluginTitle', { defaultMessage: 'Metrics', }), - euiIconType: 'metricsApp', + euiIconType: 'logoObservability', order: 8200, appRoute: '/app/metrics', category: DEFAULT_APP_CATEGORIES.observability, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx index 30294779d1a3d..7da8330740532 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; import styled from 'styled-components'; -import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiButtonEmpty } from '@elastic/eui'; +import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Section } from '../sections'; import { AlphaMessaging, SettingFlyout } from '../components'; -import { useLink, useConfig, useCore } from '../hooks'; +import { useLink, useConfig } from '../hooks'; interface Props { showSettings?: boolean; @@ -42,7 +42,6 @@ export const DefaultLayout: React.FunctionComponent = ({ }) => { const { getHref } = useLink(); const { fleet } = useConfig(); - const { uiSettings } = useCore(); const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false); return ( @@ -58,11 +57,6 @@ export const DefaultLayout: React.FunctionComponent = ({